init
58
src/App.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { NConfigProvider, darkTheme } from 'naive-ui';
|
||||
import type { WatermarkProps } from 'naive-ui';
|
||||
import { useAppStore } from './store/modules/app';
|
||||
import { useThemeStore } from './store/modules/theme';
|
||||
import { naiveDateLocales, naiveLocales } from './locales/naive';
|
||||
|
||||
defineOptions({
|
||||
name: 'App'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined));
|
||||
|
||||
const naiveLocale = computed(() => {
|
||||
return naiveLocales[appStore.locale];
|
||||
});
|
||||
|
||||
const naiveDateLocale = computed(() => {
|
||||
return naiveDateLocales[appStore.locale];
|
||||
});
|
||||
|
||||
const watermarkProps = computed<WatermarkProps>(() => {
|
||||
return {
|
||||
content: themeStore.watermarkContent,
|
||||
cross: true,
|
||||
fullscreen: true,
|
||||
fontSize: 16,
|
||||
lineHeight: 16,
|
||||
width: 384,
|
||||
height: 384,
|
||||
xOffset: 12,
|
||||
yOffset: 60,
|
||||
rotate: -15,
|
||||
zIndex: 9999
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NConfigProvider
|
||||
:theme="naiveDarkTheme"
|
||||
:theme-overrides="themeStore.naiveTheme"
|
||||
:locale="naiveLocale"
|
||||
:date-locale="naiveDateLocale"
|
||||
class="h-full"
|
||||
>
|
||||
<AppProvider>
|
||||
<RouterView class="bg-layout" />
|
||||
<NWatermark v-if="themeStore.watermark.visible" v-bind="watermarkProps" />
|
||||
</AppProvider>
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
BIN
src/assets/imgs/soybean.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
1
src/assets/svg-icon/activity.svg
Normal 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 |
1
src/assets/svg-icon/at-sign.svg
Normal 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 |
1
src/assets/svg-icon/avatar.svg
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
1
src/assets/svg-icon/banner.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
src/assets/svg-icon/cast.svg
Normal 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 |
1
src/assets/svg-icon/chrome.svg
Normal 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 |
1
src/assets/svg-icon/copy.svg
Normal 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 |
1
src/assets/svg-icon/custom-icon.svg
Normal 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 |
1
src/assets/svg-icon/empty-data.svg
Normal file
|
After Width: | Height: | Size: 77 KiB |
1
src/assets/svg-icon/expectation.svg
Normal file
|
After Width: | Height: | Size: 70 KiB |
1
src/assets/svg-icon/heart.svg
Normal 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 |
1
src/assets/svg-icon/logo.svg
Normal 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 |
1
src/assets/svg-icon/network-error.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
1
src/assets/svg-icon/no-icon.svg
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
1
src/assets/svg-icon/no-permission.svg
Normal file
|
After Width: | Height: | Size: 50 KiB |
1
src/assets/svg-icon/not-found.svg
Normal file
|
After Width: | Height: | Size: 33 KiB |
1
src/assets/svg-icon/service-error.svg
Normal file
|
After Width: | Height: | Size: 74 KiB |
1
src/assets/svg-icon/wind.svg
Normal 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 |
45
src/components/advanced/table-base.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { DataTableColumns, PaginationProps } from 'naive-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
fetchData: (pagination: PaginationProps) => Promise<{ data: any[]; itemCount: number }>;
|
||||
columns: DataTableColumns;
|
||||
pagination: PaginationProps;
|
||||
}>();
|
||||
|
||||
const data = ref<any[]>([]);
|
||||
const headerColumns = computed(() => {
|
||||
return props.columns
|
||||
.filter(col => {
|
||||
return !col.fixed;
|
||||
})
|
||||
.map(col => {
|
||||
return {
|
||||
key: col.key,
|
||||
title: col.title,
|
||||
checked: true,
|
||||
visible: true
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
const result = await props.fetchData(props.pagination);
|
||||
data.value = result.data;
|
||||
props.pagination!.itemCount = result.itemCount;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<TableHeaderOperation :columns="headerColumns" />
|
||||
<NDataTable :columns="columns" :data="data!" :pagination="pagination" :bordered="false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped></style>
|
||||
43
src/components/advanced/table-column-setting.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<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<NaiveUI.TableColumnCheck[]>('columns', {
|
||||
required: true
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NPopover placement="bottom-end" trigger="click">
|
||||
<template #trigger>
|
||||
<NButton size="small">
|
||||
<template #icon>
|
||||
<icon-ant-design-setting-outlined class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.columnSetting') }}
|
||||
</NButton>
|
||||
</template>
|
||||
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable">
|
||||
<div
|
||||
v-for="item in columns"
|
||||
:key="item.key"
|
||||
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
|
||||
:class="{ hidden: !item.visible }"
|
||||
>
|
||||
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
|
||||
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
|
||||
<template v-if="typeof item.title === 'function'">
|
||||
<component :is="item.title" />
|
||||
</template>
|
||||
<template v-else>{{ item.title }}</template>
|
||||
</NCheckbox>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</NPopover>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
74
src/components/advanced/table-header-operation.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'TableHeaderOperation'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
itemAlign?: NaiveUI.Align;
|
||||
disabledDelete?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'add'): void;
|
||||
(e: 'delete'): void;
|
||||
(e: 'refresh'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
||||
default: () => []
|
||||
});
|
||||
|
||||
function add() {
|
||||
emit('add');
|
||||
}
|
||||
|
||||
function batchDelete() {
|
||||
emit('delete');
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
emit('refresh');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace :align="itemAlign" wrap justify="end" class="lt-sm:w-200px">
|
||||
<slot name="prefix"></slot>
|
||||
<slot name="default">
|
||||
<NButton size="small" ghost type="primary" @click="add">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</NButton>
|
||||
<NPopconfirm @positive-click="batchDelete">
|
||||
<template #trigger>
|
||||
<NButton size="small" ghost type="error" :disabled="disabledDelete">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</NButton>
|
||||
</template>
|
||||
{{ $t('common.confirmDelete') }}
|
||||
</NPopconfirm>
|
||||
</slot>
|
||||
<NButton size="small" @click="refresh">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
|
||||
</template>
|
||||
{{ $t('common.refresh') }}
|
||||
</NButton>
|
||||
<TableColumnSetting v-model:columns="columns" />
|
||||
<slot name="suffix"></slot>
|
||||
</NSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
39
src/components/common/app-provider.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { createTextVNode, defineComponent } from 'vue';
|
||||
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'AppProvider'
|
||||
});
|
||||
|
||||
const ContextHolder = defineComponent({
|
||||
name: 'ContextHolder',
|
||||
setup() {
|
||||
function register() {
|
||||
window.$loadingBar = useLoadingBar();
|
||||
window.$dialog = useDialog();
|
||||
window.$message = useMessage();
|
||||
window.$notification = useNotification();
|
||||
}
|
||||
|
||||
register();
|
||||
|
||||
return () => createTextVNode();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLoadingBarProvider>
|
||||
<NDialogProvider>
|
||||
<NNotificationProvider>
|
||||
<NMessageProvider>
|
||||
<ContextHolder />
|
||||
<slot></slot>
|
||||
</NMessageProvider>
|
||||
</NNotificationProvider>
|
||||
</NDialogProvider>
|
||||
</NLoadingBarProvider>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
17
src/components/common/dark-mode-container.vue
Normal 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>
|
||||
43
src/components/common/exception-base.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ExceptionBase' });
|
||||
|
||||
type ExceptionType = '403' | '404' | '500';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Exception type
|
||||
*
|
||||
* - 403: no permission
|
||||
* - 404: not found
|
||||
* - 500: service error
|
||||
*/
|
||||
type: ExceptionType;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const iconMap: Record<ExceptionType, string> = {
|
||||
'403': 'no-permission',
|
||||
'404': 'not-found',
|
||||
'500': 'service-error'
|
||||
};
|
||||
|
||||
const icon = computed(() => iconMap[props.type]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
|
||||
<div class="flex text-400px text-primary">
|
||||
<SvgIcon :local-icon="icon" />
|
||||
</div>
|
||||
<NButton type="primary" @click="routerPushByKey('root')">{{ $t('common.backToHome') }}</NButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
22
src/components/common/full-screen.vue
Normal 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>
|
||||
42
src/components/common/icon-tooltip.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
|
||||
defineOptions({ name: 'IconTooltip' });
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
localIcon?: string;
|
||||
desc?: string;
|
||||
placement?: PopoverPlacement;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
icon: 'mdi-help-circle',
|
||||
localIcon: '',
|
||||
desc: '',
|
||||
placement: 'top'
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const hasCustomTrigger = computed(() => Boolean(slots.trigger));
|
||||
|
||||
if (!hasCustomTrigger.value && !props.icon && !props.localIcon) {
|
||||
throw new Error('icon or localIcon is required when no custom trigger slot is provided');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip :placement="placement">
|
||||
<template #trigger>
|
||||
<slot name="trigger">
|
||||
<div class="cursor-pointer">
|
||||
<SvgIcon :icon="icon" :local-icon="localIcon" />
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<slot>
|
||||
<span>{{ desc }}</span>
|
||||
</slot>
|
||||
</NTooltip>
|
||||
</template>
|
||||
61
src/components/common/lang-switch.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<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');
|
||||
});
|
||||
|
||||
/** Add bottom margin to all options except the last one for proper visual separation */
|
||||
const dropdownOptions = computed(() => {
|
||||
const lastIndex = props.langOptions.length - 1;
|
||||
|
||||
return props.langOptions.map((option, index) => ({
|
||||
...option,
|
||||
props: {
|
||||
class: index < lastIndex ? 'mb-1' : undefined
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
function changeLang(lang: App.I18n.LangType) {
|
||||
emit('changeLang', lang);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDropdown :value="lang" :options="dropdownOptions" trigger="hover" @select="changeLang">
|
||||
<div>
|
||||
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
|
||||
<SvgIcon icon="heroicons:language" />
|
||||
</ButtonIcon>
|
||||
</div>
|
||||
</NDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
53
src/components/common/menu-toggler.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuToggler' });
|
||||
|
||||
interface Props {
|
||||
/** Show collapsed icon */
|
||||
collapsed?: boolean;
|
||||
/** Arrow style icon */
|
||||
arrowIcon?: boolean;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
arrowIcon: false,
|
||||
zIndex: 98
|
||||
});
|
||||
|
||||
type NumberBool = 0 | 1;
|
||||
|
||||
const icon = computed(() => {
|
||||
const icons: Record<NumberBool, Record<NumberBool, string>> = {
|
||||
0: {
|
||||
0: 'line-md:menu-fold-left',
|
||||
1: 'line-md:menu-fold-right'
|
||||
},
|
||||
1: {
|
||||
0: 'ph-caret-double-left-bold',
|
||||
1: 'ph-caret-double-right-bold'
|
||||
}
|
||||
};
|
||||
|
||||
const arrowIcon = Number(props.arrowIcon || false) as NumberBool;
|
||||
|
||||
const collapsed = Number(props.collapsed || false) as NumberBool;
|
||||
|
||||
return icons[arrowIcon][collapsed];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon
|
||||
:key="String(collapsed)"
|
||||
:tooltip-content="collapsed ? $t('icon.expand') : $t('icon.collapse')"
|
||||
tooltip-placement="bottom-start"
|
||||
:z-index="zIndex"
|
||||
>
|
||||
<SvgIcon :icon="icon" />
|
||||
</ButtonIcon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
26
src/components/common/pin-toggler.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'PinToggler' });
|
||||
|
||||
interface Props {
|
||||
pin?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const icon = computed(() => (props.pin ? 'mdi-pin-off' : 'mdi-pin'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon
|
||||
:tooltip-content="pin ? $t('icon.unpin') : $t('icon.pin')"
|
||||
tooltip-placement="bottom-start"
|
||||
:z-index="100"
|
||||
>
|
||||
<SvgIcon :icon="icon" />
|
||||
</ButtonIcon>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
21
src/components/common/reload-button.vue
Normal 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>
|
||||
9
src/components/common/system-logo.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'SystemLogo' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<icon-local-logo />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
56
src/components/common/theme-schema-switch.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ThemeSchemaSwitch' });
|
||||
|
||||
interface Props {
|
||||
/** Theme schema */
|
||||
themeSchema: UnionKey.ThemeScheme;
|
||||
/** Show tooltip */
|
||||
showTooltip?: boolean;
|
||||
/** Tooltip placement */
|
||||
tooltipPlacement?: PopoverPlacement;
|
||||
}
|
||||
|
||||
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>
|
||||
53
src/components/custom/better-scroll.vue
Normal 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>
|
||||
48
src/components/custom/button-icon.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
defineOptions({
|
||||
name: 'ButtonIcon',
|
||||
inheritAttrs: false
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** Button class */
|
||||
class?: string;
|
||||
/** Iconify icon name */
|
||||
icon?: string;
|
||||
/** Tooltip content */
|
||||
tooltipContent?: string;
|
||||
/** Tooltip placement */
|
||||
tooltipPlacement?: PopoverPlacement;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
class: '',
|
||||
icon: '',
|
||||
tooltipContent: '',
|
||||
tooltipPlacement: 'bottom',
|
||||
zIndex: 98
|
||||
});
|
||||
|
||||
const DEFAULT_CLASS = 'h-[36px] text-icon';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTooltip :placement="tooltipPlacement" :z-index="zIndex" :disabled="!tooltipContent">
|
||||
<template #trigger>
|
||||
<NButton quaternary :class="twMerge(DEFAULT_CLASS, props.class)" v-bind="$attrs">
|
||||
<div class="flex-center gap-8px">
|
||||
<slot>
|
||||
<SvgIcon :icon="icon" />
|
||||
</slot>
|
||||
</div>
|
||||
</NButton>
|
||||
</template>
|
||||
{{ tooltipContent }}
|
||||
</NTooltip>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
88
src/components/custom/count-to.vue
Normal 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>
|
||||
20
src/components/custom/look-forward.vue
Normal 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>
|
||||
13
src/components/custom/soybean-avatar.vue
Normal 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>
|
||||
54
src/components/custom/svg-icon.vue
Normal 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>
|
||||
61
src/components/custom/wave-bg.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { getPaletteColorByNumber } from '@sa/color';
|
||||
|
||||
defineOptions({ name: 'WaveBg' });
|
||||
|
||||
interface Props {
|
||||
/** Theme color */
|
||||
themeColor: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200));
|
||||
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute-lt z-1 size-full overflow-hidden">
|
||||
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
|
||||
<svg height="1337" width="1337">
|
||||
<defs>
|
||||
<path
|
||||
id="path-1"
|
||||
opacity="1"
|
||||
fill-rule="evenodd"
|
||||
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
|
||||
/>
|
||||
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
|
||||
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
|
||||
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g opacity="1">
|
||||
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
|
||||
<svg height="896" width="967.8852157128662">
|
||||
<defs>
|
||||
<path
|
||||
id="path-2"
|
||||
opacity="1"
|
||||
fill-rule="evenodd"
|
||||
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
|
||||
/>
|
||||
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
|
||||
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
|
||||
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<g opacity="1">
|
||||
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
50
src/components/table/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { h } from 'vue';
|
||||
import type { ButtonProps, DataTableColumns } from 'naive-ui';
|
||||
import { NButton, NSpace } from 'naive-ui';
|
||||
import type { TableColumn } from 'naive-ui/es/data-table/src/interface';
|
||||
import type { safeClient } from '@/service/api';
|
||||
import type TableBase from './table-base.vue';
|
||||
|
||||
export type TableBaseColumns<T = any> = Array<
|
||||
TableColumn<T> & {
|
||||
operations?: (row: T) => Array<Partial<ButtonProps> & { contentText: string }>;
|
||||
key: string;
|
||||
title: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export type TableInst = InstanceType<typeof TableBase>;
|
||||
|
||||
export interface Pagination {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export type TableFetchData = (page: Pagination) => ReturnType<typeof safeClient>;
|
||||
|
||||
export function transformColumns<T = any>(columns: TableBaseColumns<T>): DataTableColumns<T> {
|
||||
return columns.map(col => {
|
||||
return {
|
||||
...col,
|
||||
render: col.operations
|
||||
? (row: T) =>
|
||||
h(NSpace, null, {
|
||||
default: () => col.operations!(row).map(item => h(NButton, item, { default: () => item.contentText }))
|
||||
})
|
||||
: undefined
|
||||
} as TableColumn<T>;
|
||||
});
|
||||
}
|
||||
|
||||
export function transformHeaderColumns<T = any>(columns: TableBaseColumns<T>): NaiveUI.TableColumnCheck[] {
|
||||
return columns
|
||||
.filter(col => {
|
||||
return !col.fixed;
|
||||
})
|
||||
.map(col => ({
|
||||
key: col.key,
|
||||
title: col.title,
|
||||
checked: true,
|
||||
visible: true
|
||||
}));
|
||||
}
|
||||
73
src/components/table/table-base.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import type { PaginationProps } from 'naive-ui';
|
||||
import { type TableBaseColumns, type TableFetchData, transformColumns, transformHeaderColumns } from '.';
|
||||
|
||||
const props = defineProps<{
|
||||
fetchData: TableFetchData;
|
||||
columns: TableBaseColumns;
|
||||
showHeaderOperation?: boolean;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'add'): void;
|
||||
(e: 'refresh'): void;
|
||||
(e: 'delete'): void;
|
||||
}>();
|
||||
|
||||
const tableData = ref<any[]>([]);
|
||||
const dataTableColumns = transformColumns(props.columns);
|
||||
const headerTableColumns = transformHeaderColumns(props.columns);
|
||||
const pagination = ref<PaginationProps>({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
const page = pagination.value.page || 1;
|
||||
const pageSize = pagination.value.pageSize || 10;
|
||||
const { data } = await props.fetchData({
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize
|
||||
});
|
||||
|
||||
tableData.value = (data.value as any).data;
|
||||
pagination.value.itemCount = (data.value as any).pagination.total;
|
||||
}
|
||||
|
||||
function handlePageChange(curPage: number) {
|
||||
pagination.value.page = curPage;
|
||||
loadData();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
reload: loadData
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<TableHeaderOperation
|
||||
v-if="showHeaderOperation"
|
||||
:columns="headerTableColumns"
|
||||
@add="emit('add')"
|
||||
@refresh="emit('refresh')"
|
||||
@delete="emit('delete')"
|
||||
/>
|
||||
<NDataTable
|
||||
:columns="dataTableColumns"
|
||||
:data="tableData"
|
||||
:pagination="pagination"
|
||||
:bordered="false"
|
||||
:on-update:page="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped></style>
|
||||
71
src/constants/app.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { transformRecordToOption } from '@/utils/common';
|
||||
|
||||
export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__';
|
||||
|
||||
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__';
|
||||
|
||||
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
|
||||
light: 'theme.appearance.themeSchema.light',
|
||||
dark: 'theme.appearance.themeSchema.dark',
|
||||
auto: 'theme.appearance.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.layout.layoutMode.vertical',
|
||||
'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
|
||||
'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
|
||||
horizontal: 'theme.layout.layoutMode.horizontal',
|
||||
'top-hybrid-sidebar-first': 'theme.layout.layoutMode.top-hybrid-sidebar-first',
|
||||
'top-hybrid-header-first': 'theme.layout.layoutMode.top-hybrid-header-first'
|
||||
};
|
||||
|
||||
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord);
|
||||
|
||||
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
|
||||
wrapper: 'theme.layout.content.scrollMode.wrapper',
|
||||
content: 'theme.layout.content.scrollMode.content'
|
||||
};
|
||||
|
||||
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord);
|
||||
|
||||
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
|
||||
chrome: 'theme.layout.tab.mode.chrome',
|
||||
button: 'theme.layout.tab.mode.button',
|
||||
slider: 'theme.layout.tab.mode.slider'
|
||||
};
|
||||
|
||||
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord);
|
||||
|
||||
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
|
||||
'fade-slide': 'theme.layout.content.page.mode.fade-slide',
|
||||
fade: 'theme.layout.content.page.mode.fade',
|
||||
'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
|
||||
'fade-scale': 'theme.layout.content.page.mode.fade-scale',
|
||||
'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
|
||||
'zoom-out': 'theme.layout.content.page.mode.zoom-out',
|
||||
none: 'theme.layout.content.page.mode.none'
|
||||
};
|
||||
|
||||
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord);
|
||||
|
||||
export const DARK_CLASS = 'dark';
|
||||
|
||||
export const watermarkTimeFormatOptions = [
|
||||
{ label: 'YYYY-MM-DD HH:mm', value: 'YYYY-MM-DD HH:mm' },
|
||||
{ label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' },
|
||||
{ label: 'YYYY/MM/DD HH:mm', value: 'YYYY/MM/DD HH:mm' },
|
||||
{ label: 'YYYY/MM/DD HH:mm:ss', value: 'YYYY/MM/DD HH:mm:ss' },
|
||||
{ label: 'HH:mm', value: 'HH:mm' },
|
||||
{ label: 'HH:mm:ss', value: 'HH:mm:ss' },
|
||||
{ label: 'MM-DD HH:mm', value: 'MM-DD HH:mm' }
|
||||
];
|
||||
8
src/constants/common.ts
Normal 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);
|
||||
25
src/constants/reg.ts
Normal 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]*))?)$/;
|
||||
12
src/enum/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum SetupStoreId {
|
||||
App = 'app-store',
|
||||
Theme = 'theme-store',
|
||||
Auth = 'auth-store',
|
||||
Route = 'route-store',
|
||||
Tab = 'tab-store'
|
||||
}
|
||||
|
||||
export enum DepositTypeEnum {
|
||||
crypto = '链上充值',
|
||||
fiat = '法币充值'
|
||||
}
|
||||
21
src/hooks/business/auth.ts
Normal 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
|
||||
};
|
||||
}
|
||||
70
src/hooks/business/captcha.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { computed } from 'vue';
|
||||
import { useCountDown, useLoading } from '@sa/hooks';
|
||||
import { REG_PHONE } from '@/constants/reg';
|
||||
import { fetchSendPhoneNumberOtp } from '@/service/api';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export function useCaptcha() {
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
const { count, start, stop, isCounting } = useCountDown(10);
|
||||
|
||||
const label = computed(() => {
|
||||
let text = $t('page.login.codeLogin.getCode');
|
||||
|
||||
const countingLabel = $t('page.login.codeLogin.reGetCode', { time: count.value });
|
||||
|
||||
if (loading.value) {
|
||||
text = '';
|
||||
}
|
||||
|
||||
if (isCounting.value) {
|
||||
text = countingLabel;
|
||||
}
|
||||
|
||||
return text;
|
||||
});
|
||||
|
||||
function isPhoneValid(phone: string) {
|
||||
if (phone.trim() === '') {
|
||||
window.$message?.error?.($t('form.phone.required'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!REG_PHONE.test(phone)) {
|
||||
window.$message?.error?.($t('form.phone.invalid'));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getCaptcha(phone: string) {
|
||||
const valid = isPhoneValid(phone);
|
||||
|
||||
if (!valid || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
startLoading();
|
||||
|
||||
// request
|
||||
await fetchSendPhoneNumberOtp(phone);
|
||||
|
||||
window.$message?.success?.($t('page.login.codeLogin.sendCodeSuccess'));
|
||||
|
||||
start();
|
||||
|
||||
endLoading();
|
||||
}
|
||||
|
||||
return {
|
||||
label,
|
||||
start,
|
||||
stop,
|
||||
isCounting,
|
||||
loading,
|
||||
getCaptcha
|
||||
};
|
||||
}
|
||||
230
src/hooks/common/echarts.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { computed, effectScope, nextTick, onScopeDispose, shallowRef, watch } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import * as echarts from 'echarts/core';
|
||||
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts';
|
||||
import type {
|
||||
BarSeriesOption,
|
||||
GaugeSeriesOption,
|
||||
LineSeriesOption,
|
||||
PictorialBarSeriesOption,
|
||||
PieSeriesOption,
|
||||
RadarSeriesOption,
|
||||
ScatterSeriesOption
|
||||
} from 'echarts/charts';
|
||||
import {
|
||||
DatasetComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TitleComponent,
|
||||
ToolboxComponent,
|
||||
TooltipComponent,
|
||||
TransformComponent
|
||||
} from 'echarts/components';
|
||||
import type {
|
||||
DatasetComponentOption,
|
||||
GridComponentOption,
|
||||
LegendComponentOption,
|
||||
TitleComponentOption,
|
||||
ToolboxComponentOption,
|
||||
TooltipComponentOption
|
||||
} from 'echarts/components';
|
||||
import { LabelLayout, UniversalTransition } from 'echarts/features';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
|
||||
export type ECOption = echarts.ComposeOption<
|
||||
| BarSeriesOption
|
||||
| LineSeriesOption
|
||||
| PieSeriesOption
|
||||
| ScatterSeriesOption
|
||||
| PictorialBarSeriesOption
|
||||
| RadarSeriesOption
|
||||
| GaugeSeriesOption
|
||||
| TitleComponentOption
|
||||
| LegendComponentOption
|
||||
| TooltipComponentOption
|
||||
| GridComponentOption
|
||||
| ToolboxComponentOption
|
||||
| DatasetComponentOption
|
||||
>;
|
||||
|
||||
echarts.use([
|
||||
TitleComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
ToolboxComponent,
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
ScatterChart,
|
||||
PictorialBarChart,
|
||||
RadarChart,
|
||||
GaugeChart,
|
||||
LabelLayout,
|
||||
UniversalTransition,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
interface ChartHooks {
|
||||
onRender?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
onUpdated?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
onDestroy?: (chart: echarts.ECharts) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* use echarts
|
||||
*
|
||||
* @param optionsFactory echarts options factory function
|
||||
* @param darkMode dark mode
|
||||
*/
|
||||
export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: ChartHooks = {}) {
|
||||
const scope = effectScope();
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const darkMode = computed(() => themeStore.darkMode);
|
||||
|
||||
const domRef = shallowRef<HTMLElement | null>(null);
|
||||
const initialSize = { width: 0, height: 0 };
|
||||
const { width, height } = useElementSize(domRef, initialSize);
|
||||
|
||||
const chart = shallowRef<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;
|
||||
|
||||
/** is chart rendered */
|
||||
function isRendered() {
|
||||
return Boolean(domRef.value && chart.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* update chart options
|
||||
*
|
||||
* @param callback callback function
|
||||
*/
|
||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
||||
const updatedOpts = callback(chartOptions, optionsFactory);
|
||||
|
||||
Object.assign(chartOptions, updatedOpts);
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (!isRendered()) return;
|
||||
|
||||
if (isRendered()) {
|
||||
chart.value?.clear();
|
||||
}
|
||||
|
||||
chart.value?.setOption({ ...updatedOpts, backgroundColor: 'transparent' });
|
||||
|
||||
await onUpdated?.(chart.value!);
|
||||
}
|
||||
|
||||
function setOptions(options: T) {
|
||||
chart.value?.setOption(options);
|
||||
}
|
||||
|
||||
/** render chart */
|
||||
async function render() {
|
||||
if (isRendered()) return;
|
||||
|
||||
const chartTheme = darkMode.value ? 'dark' : 'light';
|
||||
|
||||
chart.value = echarts.init(domRef.value, chartTheme);
|
||||
|
||||
chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' });
|
||||
|
||||
await onRender?.(chart.value!);
|
||||
}
|
||||
|
||||
/** resize chart */
|
||||
function resize() {
|
||||
chart.value?.resize();
|
||||
}
|
||||
|
||||
/** destroy chart */
|
||||
async function destroy() {
|
||||
if (!chart.value) return;
|
||||
|
||||
await onDestroy?.(chart.value);
|
||||
chart.value?.dispose();
|
||||
chart.value = null;
|
||||
}
|
||||
|
||||
/** change chart theme */
|
||||
async function changeTheme() {
|
||||
await destroy();
|
||||
await render();
|
||||
await onUpdated?.(chart.value!);
|
||||
}
|
||||
|
||||
/**
|
||||
* render chart by size
|
||||
*
|
||||
* @param w width
|
||||
* @param h height
|
||||
*/
|
||||
async function renderChartBySize(w: number, h: number) {
|
||||
initialSize.width = w;
|
||||
initialSize.height = h;
|
||||
|
||||
// resize chart
|
||||
if (isRendered()) {
|
||||
resize();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// render chart
|
||||
await render();
|
||||
|
||||
if (chart.value) {
|
||||
await onUpdated?.(chart.value);
|
||||
}
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch(
|
||||
[width, height],
|
||||
([newWidth, newHeight]) => {
|
||||
renderChartBySize(newWidth, newHeight);
|
||||
},
|
||||
{ flush: 'post' }
|
||||
);
|
||||
|
||||
watch(darkMode, () => {
|
||||
changeTheme();
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
destroy();
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
domRef,
|
||||
chart,
|
||||
updateOptions,
|
||||
setOptions
|
||||
};
|
||||
}
|
||||
96
src/hooks/common/form.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { ref, toValue } from 'vue';
|
||||
import type { ComputedRef, Ref } from 'vue';
|
||||
import type { FormInst } from 'naive-ui';
|
||||
import { REG_CODE_SIX, REG_EMAIL, REG_PHONE, REG_PWD } from '@/constants/reg';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export function useFormRules() {
|
||||
const patternRules = {
|
||||
userName: {
|
||||
message: $t('form.userName.invalid'),
|
||||
trigger: 'change'
|
||||
},
|
||||
phone: {
|
||||
pattern: REG_PHONE,
|
||||
message: $t('form.phone.invalid'),
|
||||
trigger: 'change'
|
||||
},
|
||||
pwd: {
|
||||
pattern: REG_PWD,
|
||||
message: $t('form.pwd.invalid'),
|
||||
trigger: 'change'
|
||||
},
|
||||
code: {
|
||||
pattern: REG_CODE_SIX,
|
||||
message: $t('form.code.invalid'),
|
||||
trigger: 'change'
|
||||
},
|
||||
email: {
|
||||
pattern: REG_EMAIL,
|
||||
message: $t('form.email.invalid'),
|
||||
trigger: 'change'
|
||||
}
|
||||
} satisfies Record<string, App.Global.FormRule>;
|
||||
|
||||
const formRules = {
|
||||
userName: [createRequiredRule($t('form.userName.required')), patternRules.userName],
|
||||
phone: [createRequiredRule($t('form.phone.required')), patternRules.phone],
|
||||
pwd: [createRequiredRule($t('form.pwd.required')), patternRules.pwd],
|
||||
code: [createRequiredRule($t('form.code.required')), patternRules.code],
|
||||
email: [createRequiredRule($t('form.email.required')), patternRules.email]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
/** the default required rule */
|
||||
const defaultRequiredRule = createRequiredRule($t('form.required'));
|
||||
|
||||
function createRequiredRule(message: string): App.Global.FormRule {
|
||||
return {
|
||||
required: true,
|
||||
message
|
||||
};
|
||||
}
|
||||
|
||||
/** create a rule for confirming the password */
|
||||
function createConfirmPwdRule(pwd: string | Ref<string> | ComputedRef<string>) {
|
||||
const confirmPwdRule: App.Global.FormRule[] = [
|
||||
{ required: true, message: $t('form.confirmPwd.required') },
|
||||
{
|
||||
asyncValidator: (rule, value) => {
|
||||
if (value.trim() !== '' && value !== toValue(pwd)) {
|
||||
return Promise.reject(rule.message);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
message: $t('form.confirmPwd.invalid'),
|
||||
trigger: 'input'
|
||||
}
|
||||
];
|
||||
return confirmPwdRule;
|
||||
}
|
||||
|
||||
return {
|
||||
patternRules,
|
||||
formRules,
|
||||
defaultRequiredRule,
|
||||
createRequiredRule,
|
||||
createConfirmPwdRule
|
||||
};
|
||||
}
|
||||
|
||||
export function useNaiveForm() {
|
||||
const formRef = ref<FormInst | null>(null);
|
||||
|
||||
async function validate() {
|
||||
await formRef.value?.validate();
|
||||
}
|
||||
|
||||
async function restoreValidation() {
|
||||
formRef.value?.restoreValidation();
|
||||
}
|
||||
|
||||
return {
|
||||
formRef,
|
||||
validate,
|
||||
restoreValidation
|
||||
};
|
||||
}
|
||||
10
src/hooks/common/icon.ts
Normal 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
|
||||
};
|
||||
}
|
||||
115
src/hooks/common/router.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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;
|
||||
|
||||
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
|
||||
const { query, params } = options || {};
|
||||
|
||||
const routeLocation: RouteLocationRaw = {
|
||||
name: key
|
||||
};
|
||||
|
||||
if (Object.keys(query || {}).length) {
|
||||
routeLocation.query = query;
|
||||
}
|
||||
|
||||
if (Object.keys(params || {}).length) {
|
||||
routeLocation.params = params;
|
||||
}
|
||||
|
||||
return routerPush(routeLocation);
|
||||
}
|
||||
|
||||
function routerPushByKeyWithMetaQuery(key: RouteKey) {
|
||||
const allRoutes = router.getRoutes();
|
||||
const meta = allRoutes.find(item => item.name === key)?.meta || null;
|
||||
|
||||
const query: Record<string, string> = {};
|
||||
|
||||
meta?.query?.forEach(item => {
|
||||
query[item.key] = item.value;
|
||||
});
|
||||
|
||||
return routerPushByKey(key, { query });
|
||||
}
|
||||
|
||||
async function toHome() {
|
||||
return routerPushByKey('root');
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to login page
|
||||
*
|
||||
* @param loginModule The login module
|
||||
* @param redirectUrl The redirect url, if not specified, it will be the current route fullPath
|
||||
*/
|
||||
async function toLogin(loginModule?: UnionKey.LoginModule, redirectUrl?: string) {
|
||||
const module = loginModule || 'pwd-login';
|
||||
|
||||
const options: App.Global.RouterPushOptions = {
|
||||
params: {
|
||||
module
|
||||
}
|
||||
};
|
||||
|
||||
const redirect = redirectUrl || route.value.fullPath;
|
||||
|
||||
options.query = {
|
||||
redirect
|
||||
};
|
||||
|
||||
return routerPushByKey('login', options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle login module
|
||||
*
|
||||
* @param module
|
||||
*/
|
||||
async function toggleLoginModule(module: UnionKey.LoginModule) {
|
||||
const query = route.value.query as Record<string, string>;
|
||||
|
||||
return routerPushByKey('login', { query, params: { module } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect from login
|
||||
*
|
||||
* @param [needRedirect=true] Whether to redirect after login. Default is `true`
|
||||
*/
|
||||
async function redirectFromLogin(needRedirect = true) {
|
||||
const redirect = route.value.query?.redirect as string;
|
||||
|
||||
if (needRedirect && redirect) {
|
||||
await routerPush(redirect);
|
||||
} else {
|
||||
await toHome();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
routerPush,
|
||||
routerBack,
|
||||
routerPushByKey,
|
||||
routerPushByKeyWithMetaQuery,
|
||||
toLogin,
|
||||
toggleLoginModule,
|
||||
redirectFromLogin
|
||||
};
|
||||
}
|
||||
311
src/hooks/common/table.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { computed, effectScope, onScopeDispose, reactive, shallowRef, watch } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { PaginationProps } from 'naive-ui';
|
||||
import { useBoolean, useTable } from '@sa/hooks';
|
||||
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
|
||||
UseTableOptions<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, Pagination>,
|
||||
'pagination' | 'getColumnChecks' | 'getColumns'
|
||||
> & {
|
||||
/**
|
||||
* get column visible
|
||||
*
|
||||
* @param column
|
||||
*
|
||||
* @default true
|
||||
*
|
||||
* @returns true if the column is visible, false otherwise
|
||||
*/
|
||||
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean;
|
||||
};
|
||||
|
||||
const SELECTION_KEY = '__selection__';
|
||||
|
||||
const EXPAND_KEY = '__expand__';
|
||||
|
||||
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
|
||||
const scope = effectScope();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
|
||||
...options,
|
||||
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
||||
getColumns
|
||||
});
|
||||
|
||||
// calculate the total width of the table this is used for horizontal scrolling
|
||||
const scrollX = computed(() => {
|
||||
return result.columns.value.reduce((acc, column) => {
|
||||
return acc + Number(column.width ?? column.minWidth ?? 120);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
scope.run(() => {
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
result.reloadColumns();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
scrollX
|
||||
};
|
||||
}
|
||||
|
||||
type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>;
|
||||
|
||||
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
|
||||
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>;
|
||||
/**
|
||||
* whether to show the total count of the table
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
showTotal?: boolean;
|
||||
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function useNaivePaginatedTable<ResponseData, ApiData>(
|
||||
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>
|
||||
) {
|
||||
const scope = effectScope();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const showTotal = computed(() => options.showTotal ?? true);
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 15, 20, 25, 30],
|
||||
prefix: showTotal.value ? page => $t('datatable.itemCount', { total: page.itemCount }) : undefined,
|
||||
onUpdatePage(page) {
|
||||
pagination.page = page;
|
||||
},
|
||||
onUpdatePageSize(pageSize) {
|
||||
pagination.pageSize = pageSize;
|
||||
pagination.page = 1;
|
||||
},
|
||||
...options.paginationProps
|
||||
}) as PaginationProps;
|
||||
|
||||
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
|
||||
const mobilePagination = computed(() => {
|
||||
const p: PaginationProps = {
|
||||
...pagination,
|
||||
pageSlot: isMobile.value ? 3 : 9,
|
||||
prefix: !isMobile.value && showTotal.value ? pagination.prefix : undefined
|
||||
};
|
||||
|
||||
return p;
|
||||
});
|
||||
|
||||
const paginationParams = computed(() => {
|
||||
const { page, pageSize } = pagination;
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize
|
||||
};
|
||||
});
|
||||
|
||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, true>({
|
||||
...options,
|
||||
pagination: true,
|
||||
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
||||
getColumns,
|
||||
onFetched: data => {
|
||||
pagination.itemCount = data.total;
|
||||
pagination.pageSize = data.pageSize;
|
||||
}
|
||||
});
|
||||
|
||||
async function getDataByPage(page: number = 1) {
|
||||
if (page !== pagination.page) {
|
||||
pagination.page = page;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await result.getData();
|
||||
}
|
||||
|
||||
scope.run(() => {
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
result.reloadColumns();
|
||||
}
|
||||
);
|
||||
|
||||
watch(paginationParams, async newVal => {
|
||||
await options.onPaginationParamsChange?.(newVal);
|
||||
|
||||
await result.getData();
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
scope.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
getDataByPage,
|
||||
pagination,
|
||||
mobilePagination
|
||||
};
|
||||
}
|
||||
|
||||
export function useTableOperate<TableData>(
|
||||
data: Ref<TableData[]>,
|
||||
idKey: keyof TableData,
|
||||
getData: () => Promise<void>
|
||||
) {
|
||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean();
|
||||
|
||||
const operateType = shallowRef<NaiveUI.TableOperateType>('add');
|
||||
|
||||
function handleAdd() {
|
||||
operateType.value = 'add';
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the editing row data */
|
||||
const editingData = shallowRef<TableData | null>(null);
|
||||
|
||||
function handleEdit(id: TableData[keyof TableData]) {
|
||||
operateType.value = 'edit';
|
||||
const findItem = data.value.find(item => item[idKey] === id) || null;
|
||||
editingData.value = jsonClone(findItem);
|
||||
|
||||
openDrawer();
|
||||
}
|
||||
|
||||
/** the checked row keys of table */
|
||||
const checkedRowKeys = shallowRef<string[]>([]);
|
||||
|
||||
/** the hook after the batch delete operation is completed */
|
||||
async function onBatchDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
checkedRowKeys.value = [];
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
/** the hook after the delete operation is completed */
|
||||
async function onDeleted() {
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
return {
|
||||
drawerVisible,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
operateType,
|
||||
handleAdd,
|
||||
editingData,
|
||||
handleEdit,
|
||||
checkedRowKeys,
|
||||
onBatchDeleted,
|
||||
onDeleted
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultTransform<ApiData>(
|
||||
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>
|
||||
): PaginationData<ApiData> {
|
||||
const { data, error } = response;
|
||||
|
||||
if (!error) {
|
||||
const { records, current, size, total } = data;
|
||||
|
||||
return {
|
||||
data: records,
|
||||
pageNum: current,
|
||||
pageSize: size,
|
||||
total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function getColumnChecks<Column extends NaiveUI.TableColumn<any>>(
|
||||
cols: Column[],
|
||||
getColumnVisible?: (column: Column) => boolean
|
||||
) {
|
||||
const checks: TableColumnCheck[] = [];
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
checks.push({
|
||||
key: column.key as string,
|
||||
title: column.title!,
|
||||
checked: true,
|
||||
visible: getColumnVisible?.(column) ?? true
|
||||
});
|
||||
} else if (column.type === 'selection') {
|
||||
checks.push({
|
||||
key: SELECTION_KEY,
|
||||
title: $t('common.check'),
|
||||
checked: true,
|
||||
visible: getColumnVisible?.(column) ?? false
|
||||
});
|
||||
} else if (column.type === 'expand') {
|
||||
checks.push({
|
||||
key: EXPAND_KEY,
|
||||
title: $t('common.expandColumn'),
|
||||
checked: true,
|
||||
visible: getColumnVisible?.(column) ?? false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
function getColumns<Column extends NaiveUI.TableColumn<any>>(cols: Column[], checks: TableColumnCheck[]) {
|
||||
const columnMap = new Map<string, Column>();
|
||||
|
||||
cols.forEach(column => {
|
||||
if (isTableColumnHasKey(column)) {
|
||||
columnMap.set(column.key as string, column);
|
||||
} else if (column.type === 'selection') {
|
||||
columnMap.set(SELECTION_KEY, column);
|
||||
} else if (column.type === 'expand') {
|
||||
columnMap.set(EXPAND_KEY, column);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredColumns = checks.filter(item => item.checked).map(check => columnMap.get(check.key) as Column);
|
||||
|
||||
return filteredColumns;
|
||||
}
|
||||
|
||||
export function isTableColumnHasKey<T>(column: NaiveUI.TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
|
||||
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key);
|
||||
}
|
||||
162
src/layouts/base-layout/index.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import type { LayoutMode } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import GlobalHeader from '../modules/global-header/index.vue';
|
||||
import GlobalSider from '../modules/global-sider/index.vue';
|
||||
import GlobalTab from '../modules/global-tab/index.vue';
|
||||
import GlobalContent from '../modules/global-content/index.vue';
|
||||
import GlobalFooter from '../modules/global-footer/index.vue';
|
||||
import ThemeDrawer from '../modules/theme-drawer/index.vue';
|
||||
import { provideMixMenuContext } from '../modules/global-menu/context';
|
||||
|
||||
defineOptions({
|
||||
name: 'BaseLayout'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { secondLevelMenus, childLevelMenus, isActiveFirstLevelMenuHasChildren } = provideMixMenuContext();
|
||||
|
||||
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'));
|
||||
|
||||
const layoutMode = computed(() => {
|
||||
const vertical: LayoutMode = 'vertical';
|
||||
const horizontal: LayoutMode = 'horizontal';
|
||||
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal;
|
||||
});
|
||||
|
||||
const headerProps = computed(() => {
|
||||
const { mode } = themeStore.layout;
|
||||
|
||||
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
|
||||
vertical: {
|
||||
showLogo: false,
|
||||
showMenu: false,
|
||||
showMenuToggler: true
|
||||
},
|
||||
'vertical-mix': {
|
||||
showLogo: false,
|
||||
showMenu: false,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'vertical-hybrid-header-first': {
|
||||
showLogo: !isActiveFirstLevelMenuHasChildren.value,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
horizontal: {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'top-hybrid-sidebar-first': {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: false
|
||||
},
|
||||
'top-hybrid-header-first': {
|
||||
showLogo: true,
|
||||
showMenu: true,
|
||||
showMenuToggler: isActiveFirstLevelMenuHasChildren.value
|
||||
}
|
||||
};
|
||||
|
||||
return headerPropsConfig[mode];
|
||||
});
|
||||
|
||||
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal');
|
||||
|
||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix');
|
||||
|
||||
const isVerticalHybridHeaderFirst = computed(() => themeStore.layout.mode === 'vertical-hybrid-header-first');
|
||||
|
||||
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
|
||||
|
||||
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
|
||||
|
||||
const siderWidth = computed(() => getSiderAndCollapsedWidth(false));
|
||||
|
||||
const siderCollapsedWidth = computed(() => getSiderAndCollapsedWidth(true));
|
||||
|
||||
function getSiderAndCollapsedWidth(isCollapsed: boolean) {
|
||||
const {
|
||||
mixChildMenuWidth,
|
||||
collapsedWidth,
|
||||
width: themeWidth,
|
||||
mixCollapsedWidth,
|
||||
mixWidth: themeMixWidth
|
||||
} = themeStore.sider;
|
||||
|
||||
const width = isCollapsed ? collapsedWidth : themeWidth;
|
||||
const mixWidth = isCollapsed ? mixCollapsedWidth : themeMixWidth;
|
||||
|
||||
if (isTopHybridHeaderFirst.value) {
|
||||
return isActiveFirstLevelMenuHasChildren.value ? width : 0;
|
||||
}
|
||||
|
||||
if (isVerticalHybridHeaderFirst.value && !isActiveFirstLevelMenuHasChildren.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const isMixMode = isVerticalMix.value || isTopHybridSidebarFirst.value || isVerticalHybridHeaderFirst.value;
|
||||
let finalWidth = isMixMode ? mixWidth : width;
|
||||
|
||||
if (isVerticalMix.value && appStore.mixSiderFixed && secondLevelMenus.value.length) {
|
||||
finalWidth += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
if (isVerticalHybridHeaderFirst.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
||||
finalWidth += mixChildMenuWidth;
|
||||
}
|
||||
|
||||
return finalWidth;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminLayout
|
||||
v-model:sider-collapse="appStore.siderCollapse"
|
||||
:mode="layoutMode"
|
||||
:scroll-el-id="LAYOUT_SCROLL_EL_ID"
|
||||
:scroll-mode="themeStore.layout.scrollMode"
|
||||
:is-mobile="appStore.isMobile"
|
||||
:full-content="appStore.fullContent"
|
||||
:fixed-top="themeStore.fixedHeaderAndTab"
|
||||
:header-height="themeStore.header.height"
|
||||
:tab-visible="themeStore.tab.visible"
|
||||
:tab-height="themeStore.tab.height"
|
||||
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
|
||||
:sider-visible="siderVisible"
|
||||
:sider-width="siderWidth"
|
||||
:sider-collapsed-width="siderCollapsedWidth"
|
||||
:footer-visible="themeStore.footer.visible"
|
||||
:footer-height="themeStore.footer.height"
|
||||
:fixed-footer="themeStore.footer.fixed"
|
||||
:right-footer="themeStore.footer.right"
|
||||
>
|
||||
<template #header>
|
||||
<GlobalHeader v-bind="headerProps" />
|
||||
</template>
|
||||
<template #tab>
|
||||
<GlobalTab />
|
||||
</template>
|
||||
<template #sider>
|
||||
<GlobalSider />
|
||||
</template>
|
||||
<GlobalMenu />
|
||||
<GlobalContent />
|
||||
<ThemeDrawer />
|
||||
<template #footer>
|
||||
<GlobalFooter />
|
||||
</template>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
#__SCROLL_EL_ID__ {
|
||||
@include scrollbar();
|
||||
}
|
||||
</style>
|
||||
13
src/layouts/blank-layout/index.vue
Normal 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>
|
||||
47
src/layouts/modules/global-breadcrumb/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalBreadcrumb'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
interface BreadcrumbContentProps {
|
||||
breadcrumb: App.Global.Menu;
|
||||
}
|
||||
|
||||
const [DefineBreadcrumbContent, BreadcrumbContent] = createReusableTemplate<BreadcrumbContentProps>();
|
||||
|
||||
function handleClickMenu(key: RouteKey) {
|
||||
routerPushByKey(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NBreadcrumb v-if="themeStore.header.breadcrumb.visible">
|
||||
<!-- define component start: BreadcrumbContent -->
|
||||
<DefineBreadcrumbContent v-slot="{ breadcrumb }">
|
||||
<div class="i-flex-y-center align-middle">
|
||||
<component :is="breadcrumb.icon" v-if="themeStore.header.breadcrumb.showIcon" class="mr-4px text-icon" />
|
||||
{{ breadcrumb.label }}
|
||||
</div>
|
||||
</DefineBreadcrumbContent>
|
||||
<!-- define component end: BreadcrumbContent -->
|
||||
|
||||
<NBreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
|
||||
<NDropdown v-if="item.options?.length" :options="item.options" @select="handleClickMenu">
|
||||
<BreadcrumbContent :breadcrumb="item" />
|
||||
</NDropdown>
|
||||
<BreadcrumbContent v-else :breadcrumb="item" />
|
||||
</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
60
src/layouts/modules/global-content/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalContent'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** Show padding for content */
|
||||
showPadding?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showPadding: true
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
const transitionName = computed(() => (themeStore.page.animate ? themeStore.page.animateMode : ''));
|
||||
|
||||
function resetScroll() {
|
||||
const el = document.querySelector(`#${LAYOUT_SCROLL_EL_ID}`);
|
||||
|
||||
el?.scrollTo({ left: 0, top: 0 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition
|
||||
:name="transitionName"
|
||||
mode="out-in"
|
||||
@before-leave="appStore.setContentXScrollable(true)"
|
||||
@after-leave="resetScroll"
|
||||
@after-enter="appStore.setContentXScrollable(false)"
|
||||
>
|
||||
<KeepAlive :include="routeStore.cacheRoutes" :exclude="routeStore.excludeCacheRoutes">
|
||||
<Suspense>
|
||||
<component
|
||||
:is="Component"
|
||||
v-if="appStore.reloadFlag"
|
||||
:key="tabStore.getTabIdByRoute(route)"
|
||||
:class="{ 'p-16px': showPadding }"
|
||||
class="flex-grow bg-layout transition-300"
|
||||
/>
|
||||
</Suspense>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
15
src/layouts/modules/global-footer/index.vue
Normal 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/soybeanjs/soybean-admin/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
|
||||
Copyright MIT © 2021 Soybean
|
||||
</a>
|
||||
</DarkModeContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeButton'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon
|
||||
icon="majesticons:color-swatch-line"
|
||||
:tooltip-content="$t('icon.themeConfig')"
|
||||
@click="appStore.openThemeDrawer"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
82
src/layouts/modules/global-header/components/user-avatar.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { VNode } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useSvgIcon } from '@/hooks/common/icon';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'UserAvatar'
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { routerPushByKey, toLogin } = useRouterPush();
|
||||
const { SvgIconVNode } = useSvgIcon();
|
||||
|
||||
function loginOrRegister() {
|
||||
toLogin();
|
||||
}
|
||||
|
||||
type DropdownKey = 'logout';
|
||||
|
||||
type DropdownOption =
|
||||
| {
|
||||
key: DropdownKey;
|
||||
label: string;
|
||||
icon?: () => VNode;
|
||||
}
|
||||
| {
|
||||
type: 'divider';
|
||||
key: string;
|
||||
};
|
||||
|
||||
const options = computed(() => {
|
||||
const opts: DropdownOption[] = [
|
||||
{
|
||||
label: $t('common.logout'),
|
||||
key: 'logout',
|
||||
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 })
|
||||
}
|
||||
];
|
||||
|
||||
return opts;
|
||||
});
|
||||
|
||||
function logout() {
|
||||
window.$dialog?.info({
|
||||
title: $t('common.tip'),
|
||||
content: $t('common.logoutConfirm'),
|
||||
positiveText: $t('common.confirm'),
|
||||
negativeText: $t('common.cancel'),
|
||||
onPositiveClick: () => {
|
||||
authStore.resetStore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDropdown(key: DropdownKey) {
|
||||
if (key === 'logout') {
|
||||
logout();
|
||||
} else {
|
||||
// If your other options are jumps from other routes, they will be directly supported here
|
||||
routerPushByKey(key);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NButton v-if="!authStore.isLogin" quaternary @click="loginOrRegister">
|
||||
{{ $t('page.login.common.loginOrRegister') }}
|
||||
</NButton>
|
||||
<NDropdown v-else placement="bottom" trigger="click" :options="options" @select="handleDropdown">
|
||||
<div>
|
||||
<ButtonIcon>
|
||||
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
|
||||
<span class="text-16px font-medium">{{ authStore.userInfo.username }}</span>
|
||||
</ButtonIcon>
|
||||
</div>
|
||||
</NDropdown>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
60
src/layouts/modules/global-header/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { useFullscreen } from '@vueuse/core';
|
||||
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import GlobalLogo from '../global-logo/index.vue';
|
||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||
import GlobalSearch from '../global-search/index.vue';
|
||||
import ThemeButton from './components/theme-button.vue';
|
||||
import UserAvatar from './components/user-avatar.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalHeader'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** Whether to show the logo */
|
||||
showLogo?: App.Global.HeaderProps['showLogo'];
|
||||
/** Whether to show the menu toggler */
|
||||
showMenuToggler?: App.Global.HeaderProps['showMenuToggler'];
|
||||
/** Whether to show the menu */
|
||||
showMenu?: App.Global.HeaderProps['showMenu'];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { isFullscreen, toggle } = useFullscreen();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DarkModeContainer class="h-full flex-y-center px-12px shadow-header">
|
||||
<GlobalLogo v-if="showLogo" class="h-full" :style="{ width: themeStore.sider.width + 'px' }" />
|
||||
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
|
||||
<div v-if="showMenu" :id="GLOBAL_HEADER_MENU_ID" class="h-full flex-y-center flex-1-hidden"></div>
|
||||
<div v-else class="h-full flex-y-center flex-1-hidden">
|
||||
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
|
||||
</div>
|
||||
<div class="h-full flex-y-center justify-end">
|
||||
<GlobalSearch v-if="themeStore.header.globalSearch.visible" />
|
||||
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
||||
<LangSwitch
|
||||
v-if="themeStore.header.multilingual.visible"
|
||||
:lang="appStore.locale"
|
||||
:lang-options="appStore.localeOptions"
|
||||
@change-lang="appStore.changeLocale"
|
||||
/>
|
||||
<ThemeSchemaSwitch
|
||||
:theme-schema="themeStore.themeScheme"
|
||||
:is-dark="themeStore.darkMode"
|
||||
@switch="themeStore.toggleThemeScheme"
|
||||
/>
|
||||
<ThemeButton />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</DarkModeContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
27
src/layouts/modules/global-logo/index.vue
Normal 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>
|
||||
109
src/layouts/modules/global-menu/components/first-level-menu.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { createReusableTemplate } from '@vueuse/core';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { transformColorWithOpacity } from '@sa/color';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
|
||||
defineOptions({
|
||||
name: 'FirstLevelMenu'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
menus: App.Global.Menu[];
|
||||
activeMenuKey?: string;
|
||||
inverted?: boolean;
|
||||
siderCollapse?: boolean;
|
||||
darkMode?: boolean;
|
||||
themeColor: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', menuKey: RouteKey): boolean;
|
||||
(e: 'toggleSiderCollapse'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
interface MixMenuItemProps {
|
||||
/** Menu item label */
|
||||
label: App.Global.Menu['label'];
|
||||
/** Menu item icon */
|
||||
icon: App.Global.Menu['icon'];
|
||||
/** Active menu item */
|
||||
active: boolean;
|
||||
/** Mini size */
|
||||
isMini?: boolean;
|
||||
}
|
||||
const [DefineMixMenuItem, MixMenuItem] = createReusableTemplate<MixMenuItemProps>();
|
||||
|
||||
const selectedBgColor = computed(() => {
|
||||
const { darkMode, themeColor } = props;
|
||||
|
||||
const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff');
|
||||
const dark = transformColorWithOpacity(themeColor, 0.3, '#000000');
|
||||
|
||||
return darkMode ? dark : light;
|
||||
});
|
||||
|
||||
function handleClickMixMenu(menuKey: RouteKey) {
|
||||
emit('select', menuKey);
|
||||
}
|
||||
|
||||
function toggleSiderCollapse() {
|
||||
emit('toggleSiderCollapse');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- define component: MixMenuItem -->
|
||||
<DefineMixMenuItem v-slot="{ label, icon, active, isMini }">
|
||||
<div
|
||||
class="mx-4px mb-6px flex-col-center cursor-pointer rounded-8px bg-transparent px-4px py-8px transition-300 hover:bg-[rgb(0,0,0,0.08)]"
|
||||
:class="{
|
||||
'text-primary selected-mix-menu': active,
|
||||
'text-white:65 hover:text-white': inverted,
|
||||
'!text-white !bg-primary': active && inverted
|
||||
}"
|
||||
>
|
||||
<component :is="icon" :class="[isMini ? 'text-icon-small' : 'text-icon-large']" />
|
||||
<p
|
||||
class="w-full ellipsis-text text-center text-12px transition-height-300"
|
||||
:class="[isMini ? 'h-0 pt-0' : 'h-20px pt-4px']"
|
||||
>
|
||||
{{ label }}
|
||||
</p>
|
||||
</div>
|
||||
</DefineMixMenuItem>
|
||||
<!-- define component end: MixMenuItem -->
|
||||
|
||||
<div class="h-full flex-col-stretch flex-1-hidden">
|
||||
<slot></slot>
|
||||
<SimpleScrollbar>
|
||||
<MixMenuItem
|
||||
v-for="menu in menus"
|
||||
:key="menu.key"
|
||||
:label="menu.label"
|
||||
:icon="menu.icon"
|
||||
:active="menu.key === activeMenuKey"
|
||||
:is-mini="siderCollapse"
|
||||
@click="handleClickMixMenu(menu.routeKey)"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
<MenuToggler
|
||||
arrow-icon
|
||||
:collapsed="siderCollapse"
|
||||
:z-index="99"
|
||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
||||
@click="toggleSiderCollapse"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.selected-mix-menu {
|
||||
background-color: v-bind(selectedBgColor);
|
||||
}
|
||||
</style>
|
||||
184
src/layouts/modules/global-menu/context/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useContext } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
|
||||
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu);
|
||||
|
||||
function useMixMenu() {
|
||||
const route = useRoute();
|
||||
const routeStore = useRouteStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { selectedKey } = useMenu();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
|
||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus);
|
||||
|
||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
||||
routeStore.menus.map(menu => {
|
||||
const { children: _, ...rest } = menu;
|
||||
|
||||
return rest;
|
||||
})
|
||||
);
|
||||
|
||||
const activeFirstLevelMenuKey = ref('');
|
||||
|
||||
function setActiveFirstLevelMenuKey(key: string) {
|
||||
activeFirstLevelMenuKey.value = key;
|
||||
}
|
||||
|
||||
function getActiveFirstLevelMenuKey() {
|
||||
const [firstLevelRouteName] = selectedKey.value.split('_');
|
||||
|
||||
setActiveFirstLevelMenuKey(firstLevelRouteName);
|
||||
}
|
||||
|
||||
const isActiveFirstLevelMenuHasChildren = computed(() => {
|
||||
if (!activeFirstLevelMenuKey.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value);
|
||||
|
||||
return Boolean(findItem?.children?.length);
|
||||
});
|
||||
|
||||
function handleSelectFirstLevelMenu(key: RouteKey) {
|
||||
setActiveFirstLevelMenuKey(key);
|
||||
|
||||
if (!isActiveFirstLevelMenuHasChildren.value) {
|
||||
routerPushByKeyWithMetaQuery(key);
|
||||
}
|
||||
}
|
||||
|
||||
const secondLevelMenus = computed<App.Global.Menu[]>(
|
||||
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
const activeSecondLevelMenuKey = ref('');
|
||||
|
||||
function setActiveSecondLevelMenuKey(key: string) {
|
||||
activeSecondLevelMenuKey.value = key;
|
||||
}
|
||||
|
||||
function getActiveSecondLevelMenuKey() {
|
||||
const keys = selectedKey.value.split('_');
|
||||
|
||||
if (keys.length < 2) {
|
||||
setActiveSecondLevelMenuKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
const [firstLevelRouteName, level2SuffixName] = keys;
|
||||
|
||||
const secondLevelRouteName = `${firstLevelRouteName}_${level2SuffixName}`;
|
||||
|
||||
setActiveSecondLevelMenuKey(secondLevelRouteName);
|
||||
}
|
||||
|
||||
const isActiveSecondLevelMenuHasChildren = computed(() => {
|
||||
if (!activeSecondLevelMenuKey.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const findItem = secondLevelMenus.value.find(item => item.key === activeSecondLevelMenuKey.value);
|
||||
|
||||
return Boolean(findItem?.children?.length);
|
||||
});
|
||||
|
||||
function handleSelectSecondLevelMenu(key: RouteKey) {
|
||||
setActiveSecondLevelMenuKey(key);
|
||||
|
||||
if (!isActiveSecondLevelMenuHasChildren.value) {
|
||||
routerPushByKeyWithMetaQuery(key);
|
||||
}
|
||||
}
|
||||
|
||||
const childLevelMenus = computed<App.Global.Menu[]>(
|
||||
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || []
|
||||
);
|
||||
|
||||
const hasChildLevelMenus = computed(() => childLevelMenus.value.length > 0);
|
||||
|
||||
function getDeepestLevelMenuKey(): RouteKey | null {
|
||||
if (!secondLevelMenus.value.length || !themeStore.sider.autoSelectFirstMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secondLevelFirstMenu = secondLevelMenus.value[0];
|
||||
|
||||
if (!secondLevelFirstMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDeepest(menu: App.Global.Menu): RouteKey {
|
||||
if (!menu.children?.length) {
|
||||
return menu.routeKey;
|
||||
}
|
||||
|
||||
return findDeepest(menu.children[0]);
|
||||
}
|
||||
|
||||
return findDeepest(secondLevelFirstMenu);
|
||||
}
|
||||
|
||||
function activeDeepestLevelMenuKey() {
|
||||
const deepestLevelMenuKey = getDeepestLevelMenuKey();
|
||||
if (!deepestLevelMenuKey) return;
|
||||
|
||||
// select the deepest second level menu
|
||||
handleSelectSecondLevelMenu(deepestLevelMenuKey);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
getActiveFirstLevelMenuKey();
|
||||
// if there are child level menus, get the active second level menu key
|
||||
if (hasChildLevelMenus.value) {
|
||||
getActiveSecondLevelMenuKey();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
firstLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
setActiveFirstLevelMenuKey,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
handleSelectFirstLevelMenu,
|
||||
getActiveFirstLevelMenuKey,
|
||||
secondLevelMenus,
|
||||
activeSecondLevelMenuKey,
|
||||
setActiveSecondLevelMenuKey,
|
||||
isActiveSecondLevelMenuHasChildren,
|
||||
handleSelectSecondLevelMenu,
|
||||
getActiveSecondLevelMenuKey,
|
||||
childLevelMenus,
|
||||
hasChildLevelMenus,
|
||||
getDeepestLevelMenuKey,
|
||||
activeDeepestLevelMenuKey
|
||||
};
|
||||
}
|
||||
|
||||
export function useMenu() {
|
||||
const route = useRoute();
|
||||
|
||||
const selectedKey = computed(() => {
|
||||
const { hideInMenu, activeMenu } = route.meta;
|
||||
const name = route.name as string;
|
||||
|
||||
const routeName = (hideInMenu ? activeMenu : name) || name;
|
||||
|
||||
return routeName;
|
||||
});
|
||||
|
||||
return {
|
||||
selectedKey
|
||||
};
|
||||
}
|
||||
40
src/layouts/modules/global-menu/index.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import VerticalMenu from './modules/vertical-menu.vue';
|
||||
import VerticalMixMenu from './modules/vertical-mix-menu.vue';
|
||||
import VerticalHybridHeaderFirst from './modules/vertical-hybrid-header-first.vue';
|
||||
import HorizontalMenu from './modules/horizontal-menu.vue';
|
||||
import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue';
|
||||
import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalMenu'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
|
||||
vertical: VerticalMenu,
|
||||
'vertical-mix': VerticalMixMenu,
|
||||
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
|
||||
horizontal: HorizontalMenu,
|
||||
'top-hybrid-sidebar-first': TopHybridSidebarFirst,
|
||||
'top-hybrid-header-first': TopHybridHeaderFirst
|
||||
};
|
||||
|
||||
return menuMap[themeStore.layout.mode];
|
||||
});
|
||||
|
||||
const reRenderVertical = computed(() => themeStore.layout.mode === 'vertical' && appStore.isMobile);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="activeMenu" :key="reRenderVertical" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
29
src/layouts/modules/global-menu/modules/horizontal-menu.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'HorizontalMenu'
|
||||
});
|
||||
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { selectedKey } = useMenu();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:value="selectedKey"
|
||||
:options="routeStore.menus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'TopHybridHeaderFirst'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const {
|
||||
firstLevelMenus,
|
||||
secondLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
handleSelectFirstLevelMenu,
|
||||
activeDeepestLevelMenuKey
|
||||
} = useMixMenuContext('TopHybridHeaderFirst');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
/**
|
||||
* Handle first level menu select
|
||||
* @param key RouteKey
|
||||
*/
|
||||
function handleSelectMenu(key: RouteKey) {
|
||||
handleSelectFirstLevelMenu(key);
|
||||
|
||||
// if there are second level menus, select the deepest one by default
|
||||
activeDeepestLevelMenuKey();
|
||||
}
|
||||
|
||||
function updateExpandedKeys() {
|
||||
if (appStore.siderCollapse || !selectedKey.value) {
|
||||
expandedKeys.value = [];
|
||||
return;
|
||||
}
|
||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
updateExpandedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:value="activeFirstLevelMenuKey"
|
||||
:options="firstLevelMenus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="handleSelectMenu"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<SimpleScrollbar>
|
||||
<NMenu
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
mode="vertical"
|
||||
:value="selectedKey"
|
||||
:collapsed="appStore.siderCollapse"
|
||||
:collapsed-width="themeStore.sider.collapsedWidth"
|
||||
:collapsed-icon-size="22"
|
||||
:options="secondLevelMenus"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'TopHybridSidebarFirst'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const {
|
||||
firstLevelMenus,
|
||||
secondLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
handleSelectFirstLevelMenu,
|
||||
activeDeepestLevelMenuKey
|
||||
} = useMixMenuContext('TopHybridSidebarFirst');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
/**
|
||||
* Handle first level menu select
|
||||
* @param key RouteKey
|
||||
*/
|
||||
function handleSelectMenu(key: RouteKey) {
|
||||
handleSelectFirstLevelMenu(key);
|
||||
|
||||
// if there are second level menus, select the deepest one by default
|
||||
activeDeepestLevelMenuKey();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:value="selectedKey"
|
||||
:options="secondLevelMenus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<div class="h-full pt-2">
|
||||
<FirstLevelMenu
|
||||
:menus="firstLevelMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import GlobalLogo from '../../global-logo/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'VerticalHybridHeaderFirst'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
|
||||
const {
|
||||
firstLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
handleSelectFirstLevelMenu,
|
||||
getActiveFirstLevelMenuKey,
|
||||
secondLevelMenus,
|
||||
activeSecondLevelMenuKey,
|
||||
isActiveSecondLevelMenuHasChildren,
|
||||
handleSelectSecondLevelMenu,
|
||||
getActiveSecondLevelMenuKey,
|
||||
childLevelMenus,
|
||||
hasChildLevelMenus,
|
||||
activeDeepestLevelMenuKey
|
||||
} = useMixMenuContext('VerticalHybridHeaderFirst');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
|
||||
|
||||
const showDrawer = computed(() => hasChildLevelMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
|
||||
|
||||
function handleSelectMixMenu(key: RouteKey) {
|
||||
handleSelectSecondLevelMenu(key);
|
||||
|
||||
if (isActiveSecondLevelMenuHasChildren.value) {
|
||||
setDrawerVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle second level menu selection based on autoSelectFirstMenu setting:
|
||||
* - When disabled: Activate first second-level menu for display only, expand third-level menu if exists
|
||||
* - When enabled: Navigate to the deepest menu automatically
|
||||
*/
|
||||
function handleSelectMenu(key: RouteKey) {
|
||||
handleSelectFirstLevelMenu(key);
|
||||
|
||||
if (secondLevelMenus.value.length === 0) return;
|
||||
|
||||
const secondFirstMenuKey = secondLevelMenus.value[0].routeKey;
|
||||
|
||||
// Case 1: autoSelectFirstMenu disabled - only activate menu for display
|
||||
if (!themeStore.sider.autoSelectFirstMenu) {
|
||||
// Check if there are third-level menus
|
||||
const hasChildren = secondLevelMenus.value.find(menu => menu.key === secondFirstMenuKey)?.children?.length;
|
||||
|
||||
// If there are third-level menus, expand them
|
||||
if (hasChildren) {
|
||||
handleSelectMixMenu(secondFirstMenuKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: autoSelectFirstMenu enabled - navigate to deepest menu
|
||||
activeDeepestLevelMenuKey();
|
||||
setDrawerVisible(false);
|
||||
}
|
||||
|
||||
function handleResetActiveMenu() {
|
||||
setDrawerVisible(false);
|
||||
|
||||
if (!appStore.mixSiderFixed) {
|
||||
getActiveFirstLevelMenuKey();
|
||||
getActiveSecondLevelMenuKey();
|
||||
}
|
||||
}
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
if (appStore.siderCollapse || !selectedKey.value) {
|
||||
expandedKeys.value = [];
|
||||
return;
|
||||
}
|
||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
updateExpandedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
||||
<NMenu
|
||||
mode="horizontal"
|
||||
:value="activeFirstLevelMenuKey"
|
||||
:options="firstLevelMenus"
|
||||
:indent="18"
|
||||
responsive
|
||||
@update:value="handleSelectMenu"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||
<FirstLevelMenu
|
||||
:menus="secondLevelMenus"
|
||||
:active-menu-key="activeSecondLevelMenuKey"
|
||||
:inverted="inverted"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMixMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
>
|
||||
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
|
||||
</FirstLevelMenu>
|
||||
<div
|
||||
class="relative h-full transition-width-300"
|
||||
:style="{
|
||||
width: appStore.mixSiderFixed && hasChildLevelMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px'
|
||||
}"
|
||||
>
|
||||
<DarkModeContainer
|
||||
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
|
||||
:inverted="inverted"
|
||||
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
|
||||
>
|
||||
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
|
||||
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
|
||||
<PinToggler
|
||||
:pin="appStore.mixSiderFixed"
|
||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
||||
@click="appStore.toggleMixSiderFixed"
|
||||
/>
|
||||
</header>
|
||||
<SimpleScrollbar>
|
||||
<NMenu
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
mode="vertical"
|
||||
:value="selectedKey"
|
||||
:options="childLevelMenus"
|
||||
:inverted="inverted"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
</DarkModeContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
63
src/layouts/modules/global-menu/modules/vertical-menu.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useMenu } from '../context';
|
||||
|
||||
defineOptions({
|
||||
name: 'VerticalMenu'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
if (appStore.siderCollapse || !selectedKey.value) {
|
||||
expandedKeys.value = [];
|
||||
return;
|
||||
}
|
||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
updateExpandedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<SimpleScrollbar>
|
||||
<NMenu
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
mode="vertical"
|
||||
:value="selectedKey"
|
||||
:collapsed="appStore.siderCollapse"
|
||||
:collapsed-width="themeStore.sider.collapsedWidth"
|
||||
:collapsed-icon-size="22"
|
||||
:options="routeStore.menus"
|
||||
:inverted="inverted"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
127
src/layouts/modules/global-menu/modules/vertical-mix-menu.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { SimpleScrollbar } from '@sa/materials';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { $t } from '@/locales';
|
||||
import { useMenu, useMixMenuContext } from '../context';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import GlobalLogo from '../../global-logo/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'VerticalMixMenu'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const routeStore = useRouteStore();
|
||||
const { routerPushByKeyWithMetaQuery } = useRouterPush();
|
||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean();
|
||||
const {
|
||||
firstLevelMenus,
|
||||
secondLevelMenus,
|
||||
activeFirstLevelMenuKey,
|
||||
isActiveFirstLevelMenuHasChildren,
|
||||
getActiveFirstLevelMenuKey,
|
||||
handleSelectFirstLevelMenu
|
||||
} = useMixMenuContext('VerticalMixMenu');
|
||||
const { selectedKey } = useMenu();
|
||||
|
||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted);
|
||||
|
||||
const hasChildMenus = computed(() => secondLevelMenus.value.length > 0);
|
||||
|
||||
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed));
|
||||
|
||||
function handleSelectMenu(key: RouteKey) {
|
||||
handleSelectFirstLevelMenu(key);
|
||||
|
||||
if (isActiveFirstLevelMenuHasChildren.value) {
|
||||
setDrawerVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResetActiveMenu() {
|
||||
setDrawerVisible(false);
|
||||
|
||||
if (!appStore.mixSiderFixed) {
|
||||
getActiveFirstLevelMenuKey();
|
||||
}
|
||||
}
|
||||
|
||||
const expandedKeys = ref<string[]>([]);
|
||||
|
||||
function updateExpandedKeys() {
|
||||
if (appStore.siderCollapse || !selectedKey.value) {
|
||||
expandedKeys.value = [];
|
||||
return;
|
||||
}
|
||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.name,
|
||||
() => {
|
||||
updateExpandedKeys();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
||||
<FirstLevelMenu
|
||||
:menus="firstLevelMenus"
|
||||
:active-menu-key="activeFirstLevelMenuKey"
|
||||
:inverted="inverted"
|
||||
:sider-collapse="appStore.siderCollapse"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:theme-color="themeStore.themeColor"
|
||||
@select="handleSelectMenu"
|
||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
||||
>
|
||||
<GlobalLogo :show-title="false" :style="{ height: themeStore.header.height + 'px' }" />
|
||||
</FirstLevelMenu>
|
||||
<div
|
||||
class="relative h-full transition-width-300"
|
||||
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
|
||||
>
|
||||
<DarkModeContainer
|
||||
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
|
||||
:inverted="inverted"
|
||||
:style="{ width: showDrawer ? themeStore.sider.mixChildMenuWidth + 'px' : '0px' }"
|
||||
>
|
||||
<header class="flex-y-center justify-between px-12px" :style="{ height: themeStore.header.height + 'px' }">
|
||||
<h2 class="text-16px text-primary font-bold">{{ $t('system.title') }}</h2>
|
||||
<PinToggler
|
||||
:pin="appStore.mixSiderFixed"
|
||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
||||
@click="appStore.toggleMixSiderFixed"
|
||||
/>
|
||||
</header>
|
||||
<SimpleScrollbar>
|
||||
<NMenu
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
mode="vertical"
|
||||
:value="selectedKey"
|
||||
:options="secondLevelMenus"
|
||||
:inverted="inverted"
|
||||
:indent="18"
|
||||
@update:value="routerPushByKeyWithMetaQuery"
|
||||
/>
|
||||
</SimpleScrollbar>
|
||||
</DarkModeContainer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'SearchFooter' });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-44px flex-y-center gap-14px px-24px">
|
||||
<span class="flex-y-center">
|
||||
<icon-mdi-keyboard-return class="operate-shadow operate-item" />
|
||||
<span>{{ $t('common.confirm') }}</span>
|
||||
</span>
|
||||
<span class="flex-y-center">
|
||||
<icon-mdi-arrow-up-thin class="operate-shadow operate-item" />
|
||||
<icon-mdi-arrow-down-thin class="operate-shadow operate-item" />
|
||||
<span>{{ $t('common.switch') }}</span>
|
||||
</span>
|
||||
<span class="flex-y-center">
|
||||
<icon-mdi-keyboard-esc class="operate-shadow operate-item" />
|
||||
<span>{{ $t('common.close') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.operate-shadow {
|
||||
box-shadow:
|
||||
inset 0 -2px #cdcde6,
|
||||
inset 0 0 1px 1px #fff,
|
||||
0 1px 2px 1px #1e235a66;
|
||||
}
|
||||
|
||||
.operate-item {
|
||||
--uno: mr-6px p-2px text-20px;
|
||||
}
|
||||
</style>
|
||||
123
src/layouts/modules/global-search/components/search-modal.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import SearchResult from './search-result.vue';
|
||||
import SearchFooter from './search-footer.vue';
|
||||
|
||||
defineOptions({ name: 'SearchModal' });
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const routeStore = useRouteStore();
|
||||
|
||||
const isMobile = computed(() => appStore.isMobile);
|
||||
|
||||
const keyword = ref('');
|
||||
const activePath = ref('');
|
||||
const resultOptions = shallowRef<App.Global.Menu[]>([]);
|
||||
|
||||
const handleSearch = useDebounceFn(search, 300);
|
||||
|
||||
const visible = defineModel<boolean>('show', { required: true });
|
||||
|
||||
function search() {
|
||||
resultOptions.value = routeStore.searchMenus.filter(menu => {
|
||||
const trimKeyword = keyword.value.toLocaleLowerCase().trim();
|
||||
const title = (menu.i18nKey ? $t(menu.i18nKey) : menu.label).toLocaleLowerCase();
|
||||
return trimKeyword && title.includes(trimKeyword);
|
||||
});
|
||||
activePath.value = resultOptions.value[0]?.routePath ?? '';
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
// handle with setTimeout to prevent user from seeing some operations
|
||||
setTimeout(() => {
|
||||
visible.value = false;
|
||||
resultOptions.value = [];
|
||||
keyword.value = '';
|
||||
}, 200);
|
||||
}
|
||||
|
||||
/** key up */
|
||||
function handleUp() {
|
||||
const { length } = resultOptions.value;
|
||||
if (length === 0) return;
|
||||
|
||||
const index = getActivePathIndex();
|
||||
if (index === -1) return;
|
||||
|
||||
const activeIndex = index === 0 ? length - 1 : index - 1;
|
||||
|
||||
activePath.value = resultOptions.value[activeIndex].routePath;
|
||||
}
|
||||
|
||||
/** key down */
|
||||
function handleDown() {
|
||||
const { length } = resultOptions.value;
|
||||
if (length === 0) return;
|
||||
|
||||
const index = getActivePathIndex();
|
||||
if (index === -1) return;
|
||||
|
||||
const activeIndex = index === length - 1 ? 0 : index + 1;
|
||||
|
||||
activePath.value = resultOptions.value[activeIndex].routePath;
|
||||
}
|
||||
|
||||
function getActivePathIndex() {
|
||||
return resultOptions.value.findIndex(item => item.routePath === activePath.value);
|
||||
}
|
||||
|
||||
/** key enter */
|
||||
function handleEnter() {
|
||||
if (resultOptions.value?.length === 0 || activePath.value === '') return;
|
||||
handleClose();
|
||||
router.push(activePath.value);
|
||||
}
|
||||
|
||||
function registerShortcut() {
|
||||
onKeyStroke('Escape', handleClose);
|
||||
onKeyStroke('Enter', handleEnter);
|
||||
onKeyStroke('ArrowUp', handleUp);
|
||||
onKeyStroke('ArrowDown', handleDown);
|
||||
}
|
||||
|
||||
registerShortcut();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
v-model:show="visible"
|
||||
:segmented="{ footer: 'soft' }"
|
||||
:closable="false"
|
||||
preset="card"
|
||||
auto-focus
|
||||
footer-style="padding: 0; margin: 0"
|
||||
class="fixed left-0 right-0"
|
||||
:class="[isMobile ? 'size-full top-0px rounded-0' : 'w-630px top-50px']"
|
||||
@after-leave="handleClose"
|
||||
>
|
||||
<NInputGroup>
|
||||
<NInput v-model:value="keyword" clearable :placeholder="$t('common.keywordSearch')" @input="handleSearch">
|
||||
<template #prefix>
|
||||
<icon-uil-search class="text-15px text-#c2c2c2" />
|
||||
</template>
|
||||
</NInput>
|
||||
<NButton v-if="isMobile" type="primary" ghost @click="handleClose">{{ $t('common.cancel') }}</NButton>
|
||||
</NInputGroup>
|
||||
|
||||
<div class="mt-20px">
|
||||
<NEmpty v-if="resultOptions.length === 0" :description="$t('common.noData')" />
|
||||
<SearchResult v-else v-model:path="activePath" :options="resultOptions" @enter="handleEnter" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<SearchFooter v-if="!isMobile" />
|
||||
</template>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script lang="ts" setup>
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'SearchResult' });
|
||||
|
||||
interface Props {
|
||||
options: App.Global.Menu[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'enter'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const active = defineModel<string>('path', { required: true });
|
||||
|
||||
async function handleMouseEnter(item: App.Global.Menu) {
|
||||
active.value = item.routePath;
|
||||
}
|
||||
|
||||
function handleTo() {
|
||||
emit('enter');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NScrollbar>
|
||||
<div class="pb-12px">
|
||||
<template v-for="item in options" :key="item.routePath">
|
||||
<div
|
||||
class="mt-8px h-56px flex-y-center cursor-pointer justify-between rounded-4px bg-#e5e7eb px-14px dark:bg-dark"
|
||||
:style="{
|
||||
background: item.routePath === active ? theme.themeColor : '',
|
||||
color: item.routePath === active ? '#fff' : ''
|
||||
}"
|
||||
@click="handleTo"
|
||||
@mouseenter="handleMouseEnter(item)"
|
||||
>
|
||||
<component :is="item.icon" />
|
||||
<span class="ml-5px flex-1">
|
||||
{{ (item.i18nKey && $t(item.i18nKey)) || item.label }}
|
||||
</span>
|
||||
<icon-ant-design-enter-outlined class="icon mr-3px p-2px text-20px" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NScrollbar>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
18
src/layouts/modules/global-search/index.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { $t } from '@/locales';
|
||||
import SearchModal from './components/search-modal.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalSearch' });
|
||||
|
||||
const { bool: show, toggle } = useBoolean();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ButtonIcon :tooltip-content="$t('common.search')" @click="toggle">
|
||||
<icon-uil-search />
|
||||
</ButtonIcon>
|
||||
<SearchModal v-model:show="show" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
36
src/layouts/modules/global-sider/index.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import GlobalLogo from '../global-logo/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalSider'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first');
|
||||
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first');
|
||||
const darkMenu = computed(
|
||||
() =>
|
||||
!themeStore.darkMode && !isTopHybridSidebarFirst.value && !isTopHybridHeaderFirst.value && themeStore.sider.inverted
|
||||
);
|
||||
const showLogo = computed(() => themeStore.layout.mode === 'vertical');
|
||||
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DarkModeContainer class="size-full flex-col-stretch shadow-sider" :inverted="darkMenu">
|
||||
<GlobalLogo
|
||||
v-if="showLogo"
|
||||
:show-title="!appStore.siderCollapse"
|
||||
:style="{ height: themeStore.header.height + 'px' }"
|
||||
/>
|
||||
<div :id="GLOBAL_SIDER_MENU_ID" :class="menuWrapperClass"></div>
|
||||
</DarkModeContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
146
src/layouts/modules/global-tab/context-menu.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { VNode } from 'vue';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
import { useSvgIcon } from '@/hooks/common/icon';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'ContextMenu'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
/** ClientX */
|
||||
x: number;
|
||||
/** ClientY */
|
||||
y: number;
|
||||
tabId: string;
|
||||
excludeKeys?: App.Global.DropdownKey[];
|
||||
disabledKeys?: App.Global.DropdownKey[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
excludeKeys: () => [],
|
||||
disabledKeys: () => []
|
||||
});
|
||||
|
||||
const visible = defineModel<boolean>('visible');
|
||||
|
||||
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs, fixTab, unfixTab, isTabRetain, homeTab } = useTabStore();
|
||||
const { SvgIconVNode } = useSvgIcon();
|
||||
|
||||
type DropdownOption = {
|
||||
key: App.Global.DropdownKey;
|
||||
label: string;
|
||||
icon?: () => VNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const options = computed(() => {
|
||||
const opts: DropdownOption[] = [
|
||||
{
|
||||
key: 'closeCurrent',
|
||||
label: $t('dropdown.closeCurrent'),
|
||||
icon: SvgIconVNode({ icon: 'ant-design:close-outlined', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
key: 'closeOther',
|
||||
label: $t('dropdown.closeOther'),
|
||||
icon: SvgIconVNode({ icon: 'ant-design:column-width-outlined', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
key: 'closeLeft',
|
||||
label: $t('dropdown.closeLeft'),
|
||||
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-left', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
key: 'closeRight',
|
||||
label: $t('dropdown.closeRight'),
|
||||
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-right', fontSize: 18 })
|
||||
},
|
||||
{
|
||||
key: 'closeAll',
|
||||
label: $t('dropdown.closeAll'),
|
||||
icon: SvgIconVNode({ icon: 'ant-design:line-outlined', fontSize: 18 })
|
||||
}
|
||||
];
|
||||
|
||||
if (props.tabId !== homeTab?.id) {
|
||||
if (isTabRetain(props.tabId)) {
|
||||
opts.push({
|
||||
key: 'unpin',
|
||||
label: $t('dropdown.unpin'),
|
||||
icon: SvgIconVNode({ icon: 'mdi:pin-off-outline', fontSize: 18 })
|
||||
});
|
||||
} else {
|
||||
opts.push({
|
||||
key: 'pin',
|
||||
label: $t('dropdown.pin'),
|
||||
icon: SvgIconVNode({ icon: 'mdi:pin-outline', fontSize: 18 })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { excludeKeys, disabledKeys } = props;
|
||||
|
||||
const result = opts.filter(opt => !excludeKeys.includes(opt.key));
|
||||
|
||||
disabledKeys.forEach(key => {
|
||||
const opt = result.find(item => item.key === key);
|
||||
|
||||
if (opt) {
|
||||
opt.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
function hideDropdown() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
pin() {
|
||||
fixTab(props.tabId);
|
||||
},
|
||||
unpin() {
|
||||
unfixTab(props.tabId);
|
||||
}
|
||||
};
|
||||
|
||||
function handleDropdown(optionKey: App.Global.DropdownKey) {
|
||||
dropdownAction[optionKey]?.();
|
||||
hideDropdown();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDropdown
|
||||
:show="visible"
|
||||
placement="bottom-start"
|
||||
trigger="manual"
|
||||
:x="x"
|
||||
:y="y"
|
||||
:options="options"
|
||||
@clickoutside="hideDropdown"
|
||||
@select="handleDropdown"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
233
src/layouts/modules/global-tab/index.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useElementBounding } from '@vueuse/core';
|
||||
import { PageTab } from '@sa/materials';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
import { isPC } from '@/utils/agent';
|
||||
import BetterScroll from '@/components/custom/better-scroll.vue';
|
||||
import ContextMenu from './context-menu.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalTab'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
const tabStore = useTabStore();
|
||||
|
||||
const bsWrapper = ref<HTMLElement>();
|
||||
const { width: bsWrapperWidth, left: bsWrapperLeft } = useElementBounding(bsWrapper);
|
||||
const bsScroll = ref<InstanceType<typeof BetterScroll>>();
|
||||
const tabRef = ref<HTMLElement>();
|
||||
const isPCFlag = isPC();
|
||||
|
||||
const TAB_DATA_ID = 'data-tab-id';
|
||||
const MIDDLE_MOUSE_BUTTON = 1;
|
||||
const RIGHT_MOUSE_BUTTON = 2;
|
||||
|
||||
type TabNamedNodeMap = NamedNodeMap & {
|
||||
[TAB_DATA_ID]: Attr;
|
||||
};
|
||||
|
||||
async function scrollToActiveTab() {
|
||||
await nextTick();
|
||||
if (!tabRef.value) return;
|
||||
|
||||
const { children } = tabRef.value;
|
||||
|
||||
for (let i = 0; i < children.length; i += 1) {
|
||||
const child = children[i];
|
||||
|
||||
const { value: tabId } = (child.attributes as TabNamedNodeMap)[TAB_DATA_ID];
|
||||
|
||||
if (tabId === tabStore.activeTabId) {
|
||||
const { left, width } = child.getBoundingClientRect();
|
||||
const clientX = left + width / 2;
|
||||
|
||||
setTimeout(() => {
|
||||
scrollByClientX(clientX);
|
||||
}, 50);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollByClientX(clientX: number) {
|
||||
const currentX = clientX - bsWrapperLeft.value;
|
||||
const deltaX = currentX - bsWrapperWidth.value / 2;
|
||||
|
||||
if (bsScroll.value?.instance) {
|
||||
const { maxScrollX, x: leftX, scrollBy } = bsScroll.value.instance;
|
||||
|
||||
const rightX = maxScrollX - leftX;
|
||||
const update = deltaX > 0 ? Math.max(-deltaX, rightX) : Math.min(-deltaX, -leftX);
|
||||
|
||||
scrollBy(update, 0, 300);
|
||||
}
|
||||
}
|
||||
|
||||
function getContextMenuDisabledKeys(tabId: string) {
|
||||
const disabledKeys: App.Global.DropdownKey[] = [];
|
||||
|
||||
if (tabStore.isTabRetain(tabId)) {
|
||||
const homeDisable: App.Global.DropdownKey[] = ['closeCurrent', 'closeLeft'];
|
||||
disabledKeys.push(...homeDisable);
|
||||
}
|
||||
|
||||
return disabledKeys;
|
||||
}
|
||||
|
||||
function handleCloseTab(tab: App.Global.Tab) {
|
||||
tabStore.removeTab(tab.id);
|
||||
}
|
||||
|
||||
function handleMousedown(e: MouseEvent, tab: App.Global.Tab) {
|
||||
const isMiddleClick = e.button === MIDDLE_MOUSE_BUTTON;
|
||||
if (!isMiddleClick || !themeStore.tab.closeTabByMiddleClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tabStore.isTabRetain(tab.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
handleCloseTab(tab);
|
||||
}
|
||||
|
||||
function switchTab(e: MouseEvent, tab: App.Global.Tab) {
|
||||
if ([MIDDLE_MOUSE_BUTTON, RIGHT_MOUSE_BUTTON].includes(e.button)) return;
|
||||
|
||||
tabStore.switchRouteByTab(tab);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
appStore.reloadPage(500);
|
||||
}
|
||||
|
||||
interface DropdownConfig {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
const dropdown: DropdownConfig = reactive({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
tabId: ''
|
||||
});
|
||||
|
||||
function setDropdown(config: Partial<DropdownConfig>) {
|
||||
Object.assign(dropdown, config);
|
||||
}
|
||||
|
||||
let isClickContextMenu = false;
|
||||
|
||||
function handleDropdownVisible(visible: boolean | undefined) {
|
||||
if (!isClickContextMenu) {
|
||||
setDropdown({ visible });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleContextMenu(e: MouseEvent, tabId: string) {
|
||||
e.preventDefault();
|
||||
|
||||
const { clientX, clientY } = e;
|
||||
|
||||
isClickContextMenu = true;
|
||||
|
||||
const DURATION = dropdown.visible ? 150 : 0;
|
||||
|
||||
setDropdown({ visible: false });
|
||||
|
||||
setTimeout(() => {
|
||||
setDropdown({
|
||||
visible: true,
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
tabId
|
||||
});
|
||||
isClickContextMenu = false;
|
||||
}, DURATION);
|
||||
}
|
||||
|
||||
function init() {
|
||||
tabStore.initTabStore(route);
|
||||
}
|
||||
|
||||
function removeFocus() {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
|
||||
// watch
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
tabStore.addTab(route);
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => tabStore.activeTabId,
|
||||
() => {
|
||||
scrollToActiveTab();
|
||||
}
|
||||
);
|
||||
|
||||
// init
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DarkModeContainer class="size-full flex-y-center px-16px shadow-tab">
|
||||
<div ref="bsWrapper" class="h-full flex-1-hidden">
|
||||
<BetterScroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: !isPCFlag }" @click="removeFocus">
|
||||
<div
|
||||
ref="tabRef"
|
||||
class="h-full flex pr-18px"
|
||||
:class="[
|
||||
themeStore.tab.mode === 'chrome' || themeStore.tab.mode === 'slider' ? 'items-end' : 'items-center gap-12px'
|
||||
]"
|
||||
>
|
||||
<PageTab
|
||||
v-for="tab in tabStore.tabs"
|
||||
:key="tab.id"
|
||||
:[TAB_DATA_ID]="tab.id"
|
||||
:mode="themeStore.tab.mode"
|
||||
:dark-mode="themeStore.darkMode"
|
||||
:active="tab.id === tabStore.activeTabId"
|
||||
:active-color="themeStore.themeColor"
|
||||
:closable="!tabStore.isTabRetain(tab.id)"
|
||||
@pointerdown="switchTab($event, tab)"
|
||||
@mousedown="handleMousedown($event, tab)"
|
||||
@close="handleCloseTab(tab)"
|
||||
@contextmenu="handleContextMenu($event, tab.id)"
|
||||
>
|
||||
<template #prefix>
|
||||
<SvgIcon :icon="tab.icon" :local-icon="tab.localIcon" class="inline-block align-text-bottom text-16px" />
|
||||
</template>
|
||||
<div class="max-w-240px ellipsis-text">{{ tab.label }}</div>
|
||||
</PageTab>
|
||||
</div>
|
||||
</BetterScroll>
|
||||
</div>
|
||||
<ReloadButton :loading="!appStore.reloadFlag" @click="refresh" />
|
||||
<FullScreen :full="appStore.fullContent" @click="appStore.toggleFullContent" />
|
||||
</DarkModeContainer>
|
||||
<ContextMenu
|
||||
:visible="dropdown.visible"
|
||||
:tab-id="dropdown.tabId"
|
||||
:disabled-keys="getContextMenuDisabledKeys(dropdown.tabId)"
|
||||
:x="dropdown.x"
|
||||
:y="dropdown.y"
|
||||
@update:visible="handleDropdownVisible"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
101
src/layouts/modules/theme-drawer/components/layout-mode-card.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverPlacement } from 'naive-ui';
|
||||
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: PopoverPlacement;
|
||||
menuClass: string;
|
||||
mainClass: string;
|
||||
}
|
||||
>;
|
||||
|
||||
const layoutConfig: LayoutConfig = {
|
||||
vertical: {
|
||||
placement: 'bottom',
|
||||
menuClass: 'w-1/3 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
'vertical-mix': {
|
||||
placement: 'bottom',
|
||||
menuClass: 'w-1/4 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
'vertical-hybrid-header-first': {
|
||||
placement: 'bottom',
|
||||
menuClass: 'w-1/4 h-full',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
horizontal: {
|
||||
placement: 'bottom',
|
||||
menuClass: 'w-full h-1/4',
|
||||
mainClass: 'w-full h-3/4'
|
||||
},
|
||||
'top-hybrid-sidebar-first': {
|
||||
placement: 'bottom',
|
||||
menuClass: 'w-full h-1/4',
|
||||
mainClass: 'w-2/3 h-3/4'
|
||||
},
|
||||
'top-hybrid-header-first': {
|
||||
placement: 'bottom',
|
||||
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="grid grid-cols-2 gap-x-16px gap-y-12px md:grid-cols-3">
|
||||
<div
|
||||
v-for="(item, key) in layoutConfig"
|
||||
:key="key"
|
||||
class="flex-col-center cursor-pointer"
|
||||
@click="handleChangeMode(key)"
|
||||
>
|
||||
<IconTooltip :placement="item.placement">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="h-64px w-96px gap-6px rd-4px p-6px shadow ring-2 ring-transparent transition-all hover:ring-primary"
|
||||
:class="{ '!ring-primary': mode === key }"
|
||||
>
|
||||
<div class="h-full w-full gap-1" :class="[key.includes('vertical') ? 'flex' : 'flex-col']">
|
||||
<slot :name="key"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
{{ $t(`theme.layout.layoutMode.${key}_detail`) }}
|
||||
</IconTooltip>
|
||||
<p class="mt-8px text-12px">{{ $t(themeLayoutModeRecord[key]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
24
src/layouts/modules/theme-drawer/components/setting-item.vue
Normal 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 class="flex-y-center">
|
||||
<span class="pr-8px text-base-text">{{ label }}</span>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
66
src/layouts/modules/theme-drawer/index.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { $t } from '@/locales';
|
||||
import AppearanceSettings from './modules/appearance/index.vue';
|
||||
import LayoutSettings from './modules/layout/index.vue';
|
||||
import GeneralSettings from './modules/general/index.vue';
|
||||
import ConfigOperation from './modules/config-operation.vue';
|
||||
import PresetSettings from './modules/preset/index.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeDrawer'
|
||||
});
|
||||
|
||||
const appStore = useAppStore();
|
||||
const activeTab = ref('appearance');
|
||||
|
||||
const drawerWidth = computed(() => {
|
||||
const width = 400;
|
||||
|
||||
// On mobile devices, use 90% of viewport width with a maximum of 400px
|
||||
if (appStore.isMobile) {
|
||||
return `min(90vw, ${width}px)`;
|
||||
}
|
||||
|
||||
return width;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="drawerWidth">
|
||||
<NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable>
|
||||
<NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
|
||||
<NTab name="appearance" :tab="$t('theme.tabs.appearance')"></NTab>
|
||||
<NTab name="layout" :tab="$t('theme.tabs.layout')"></NTab>
|
||||
<NTab name="general" :tab="$t('theme.tabs.general')"></NTab>
|
||||
<NTab name="preset" :tab="$t('theme.tabs.preset')"></NTab>
|
||||
</NTabs>
|
||||
|
||||
<div class="min-h-400px">
|
||||
<KeepAlive>
|
||||
<AppearanceSettings v-if="activeTab === 'appearance'" />
|
||||
<LayoutSettings v-else-if="activeTab === 'layout'" />
|
||||
<GeneralSettings v-else-if="activeTab === 'general'" />
|
||||
<PresetSettings v-else-if="activeTab === 'preset'" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ConfigOperation />
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-tab) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.n-tab-pane) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import ThemeSchema from './modules/theme-schema.vue';
|
||||
import ThemeColor from './modules/theme-color.vue';
|
||||
import ThemeRadius from './modules/theme-radius.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'AppearanceSettings'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<ThemeSchema />
|
||||
<ThemeColor />
|
||||
<ThemeRadius />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeColor'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
function handleUpdateColor(color: string, key: App.Theme.ThemeColorKey) {
|
||||
themeStore.updateThemeColors(key, color);
|
||||
}
|
||||
|
||||
const swatches: string[] = [
|
||||
'#3b82f6',
|
||||
'#6366f1',
|
||||
'#8b5cf6',
|
||||
'#a855f7',
|
||||
'#0ea5e9',
|
||||
'#06b6d4',
|
||||
'#f43f5e',
|
||||
'#ef4444',
|
||||
'#ec4899',
|
||||
'#d946ef',
|
||||
'#f97316',
|
||||
'#f59e0b',
|
||||
'#eab308',
|
||||
'#84cc16',
|
||||
'#22c55e',
|
||||
'#10b981'
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.appearance.themeColor.title') }}</NDivider>
|
||||
<div class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="recommend-color" :label="$t('theme.appearance.recommendColor')">
|
||||
<template #suffix>
|
||||
<IconTooltip>
|
||||
<p>
|
||||
<span class="pr-12px">{{ $t('theme.appearance.recommendColorDesc') }}</span>
|
||||
<br />
|
||||
<NButton
|
||||
text
|
||||
tag="a"
|
||||
href="https://uicolors.app/create"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-gray"
|
||||
>
|
||||
https://uicolors.app/create
|
||||
</NButton>
|
||||
</p>
|
||||
</IconTooltip>
|
||||
</template>
|
||||
<NSwitch v-model:value="themeStore.recommendColor" />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
v-for="(_, key) in themeStore.themeColors"
|
||||
:key="key"
|
||||
:label="$t(`theme.appearance.themeColor.${key}`)"
|
||||
>
|
||||
<template v-if="key === 'info'" #suffix>
|
||||
<NCheckbox v-model:checked="themeStore.isInfoFollowPrimary">
|
||||
{{ $t('theme.appearance.themeColor.followPrimary') }}
|
||||
</NCheckbox>
|
||||
</template>
|
||||
<NColorPicker
|
||||
class="w-90px"
|
||||
:value="themeStore.themeColors[key]"
|
||||
:disabled="key === 'info' && themeStore.isInfoFollowPrimary"
|
||||
:show-alpha="false"
|
||||
:swatches="swatches"
|
||||
@update:value="handleUpdateColor($event, key)"
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeRadius'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.appearance.themeRadius.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.appearance.themeRadius.title')">
|
||||
<NInputNumber v-model:value="themeStore.themeRadius" size="small" :step="1" :min="0" :max="16" class="w-120px" />
|
||||
</SettingItem>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { themeSchemaRecord } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeSchema'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const icons: Record<UnionKey.ThemeScheme, string> = {
|
||||
light: 'material-symbols:sunny',
|
||||
dark: 'material-symbols:nightlight-rounded',
|
||||
auto: 'material-symbols:hdr-auto'
|
||||
};
|
||||
|
||||
function handleSegmentChange(value: string | number) {
|
||||
themeStore.setThemeScheme(value as UnionKey.ThemeScheme);
|
||||
}
|
||||
|
||||
function handleGrayscaleChange(value: boolean) {
|
||||
themeStore.setGrayscale(value);
|
||||
}
|
||||
|
||||
function handleColourWeaknessChange(value: boolean) {
|
||||
themeStore.setColourWeakness(value);
|
||||
}
|
||||
|
||||
const showSiderInverted = computed(() => !themeStore.darkMode && themeStore.layout.mode.includes('vertical'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.appearance.themeSchema.title') }}</NDivider>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<div class="i-flex-center">
|
||||
<NTabs
|
||||
:key="themeStore.themeScheme"
|
||||
type="segment"
|
||||
size="small"
|
||||
class="relative w-214px"
|
||||
:value="themeStore.themeScheme"
|
||||
@update:value="handleSegmentChange"
|
||||
>
|
||||
<NTab v-for="(_, key) in themeSchemaRecord" :key="key" :name="key">
|
||||
<SvgIcon :icon="icons[key]" class="h-23px text-icon-small" />
|
||||
</NTab>
|
||||
</NTabs>
|
||||
</div>
|
||||
<Transition name="sider-inverted">
|
||||
<SettingItem v-if="showSiderInverted" :label="$t('theme.layout.sider.inverted')">
|
||||
<NSwitch v-model:value="themeStore.sider.inverted" />
|
||||
</SettingItem>
|
||||
</Transition>
|
||||
<SettingItem :label="$t('theme.appearance.grayscale')">
|
||||
<NSwitch :value="themeStore.grayscale" @update:value="handleGrayscaleChange" />
|
||||
</SettingItem>
|
||||
<SettingItem :label="$t('theme.appearance.colourWeakness')">
|
||||
<NSwitch :value="themeStore.colourWeakness" @update:value="handleColourWeaknessChange" />
|
||||
</SettingItem>
|
||||
</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>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Clipboard from 'clipboard';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'ConfigOperation'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const domRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function initClipboard() {
|
||||
if (!domRef.value) return;
|
||||
|
||||
const clipboard = new Clipboard(domRef.value);
|
||||
|
||||
clipboard.on('success', () => {
|
||||
window.$message?.success($t('theme.configOperation.copySuccessMsg'));
|
||||
});
|
||||
}
|
||||
|
||||
function getClipboardText() {
|
||||
const reg = /"\w+":/g;
|
||||
|
||||
const json = themeStore.settingsJson;
|
||||
|
||||
return json.replace(reg, match => match.replace(/"/g, ''));
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
themeStore.resetStore();
|
||||
|
||||
setTimeout(() => {
|
||||
window.$message?.success($t('theme.configOperation.resetSuccessMsg'));
|
||||
}, 50);
|
||||
}
|
||||
|
||||
const dataClipboardText = computed(() => getClipboardText());
|
||||
|
||||
onMounted(() => {
|
||||
initClipboard();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full flex justify-between">
|
||||
<textarea id="themeConfigCopyTarget" v-model="dataClipboardText" class="absolute opacity-0 -z-1" />
|
||||
<NButton type="error" ghost @click="handleReset">{{ $t('theme.configOperation.resetConfig') }}</NButton>
|
||||
<div ref="domRef" data-clipboard-target="#themeConfigCopyTarget">
|
||||
<NButton type="primary">{{ $t('theme.configOperation.copyConfig') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
17
src/layouts/modules/theme-drawer/modules/general/index.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import GlobalSettings from './modules/global-settings.vue';
|
||||
import WatermarkSettings from './modules/watermark-settings.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GeneralSettings'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<GlobalSettings />
|
||||
<WatermarkSettings />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.general.title') }}</NDivider>
|
||||
<SettingItem :label="$t('theme.general.multilingual.visible')">
|
||||
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem :label="$t('theme.general.globalSearch.visible')">
|
||||
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
|
||||
</SettingItem>
|
||||
</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>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { watermarkTimeFormatOptions } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'WatermarkSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isWatermarkTextVisible = computed(
|
||||
() => themeStore.watermark.visible && !themeStore.watermark.enableUserName && !themeStore.watermark.enableTime
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.general.watermark.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.general.watermark.visible')">
|
||||
<NSwitch v-model:value="themeStore.watermark.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.watermark.visible" key="2" :label="$t('theme.general.watermark.enableUserName')">
|
||||
<NSwitch :value="themeStore.watermark.enableUserName" @update:value="themeStore.setWatermarkEnableUserName" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.watermark.visible" key="3" :label="$t('theme.general.watermark.enableTime')">
|
||||
<NSwitch :value="themeStore.watermark.enableTime" @update:value="themeStore.setWatermarkEnableTime" />
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
v-if="themeStore.watermark.visible && themeStore.watermark.enableTime"
|
||||
key="4"
|
||||
:label="$t('theme.general.watermark.timeFormat')"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="themeStore.watermark.timeFormat"
|
||||
:options="watermarkTimeFormatOptions"
|
||||
size="small"
|
||||
class="w-210px"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isWatermarkTextVisible" key="5" :label="$t('theme.general.watermark.text')">
|
||||
<NInput
|
||||
v-model:value="themeStore.watermark.text"
|
||||
autosize
|
||||
type="text"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
placeholder="SoybeanAdmin"
|
||||
/>
|
||||
</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>
|
||||
29
src/layouts/modules/theme-drawer/modules/layout/index.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import LayoutMode from './modules/layout-mode.vue';
|
||||
import TabSettings from './modules/tab-settings.vue';
|
||||
import HeaderSettings from './modules/header-settings.vue';
|
||||
import SiderSettings from './modules/sider-settings.vue';
|
||||
import FooterSettings from './modules/footer-settings.vue';
|
||||
import ContentSettings from './modules/content-settings.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<LayoutMode />
|
||||
<TabSettings />
|
||||
<HeaderSettings />
|
||||
<!-- The top menu mode does not have a sidebar -->
|
||||
<SiderSettings v-if="themeStore.layout.mode !== 'horizontal'" />
|
||||
<FooterSettings />
|
||||
<ContentSettings />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { themePageAnimationModeOptions, themeScrollModeOptions } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ContentSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.layout.content.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.layout.content.scrollMode.title')">
|
||||
<template #suffix>
|
||||
<IconTooltip :desc="$t('theme.layout.content.scrollMode.tip')" />
|
||||
</template>
|
||||
<NSelect
|
||||
v-model:value="themeStore.layout.scrollMode"
|
||||
:options="translateOptions(themeScrollModeOptions)"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem key="2" :label="$t('theme.layout.content.page.animate')">
|
||||
<NSwitch v-model:value="themeStore.page.animate" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.page.animate" key="3" :label="$t('theme.layout.content.page.mode.title')">
|
||||
<NSelect
|
||||
v-model:value="themeStore.page.animateMode"
|
||||
:options="translateOptions(themePageAnimationModeOptions)"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isWrapperScrollMode" key="4" :label="$t('theme.layout.content.fixedHeaderAndTab')">
|
||||
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
|
||||
</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>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'FooterSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const layoutMode = computed(() => themeStore.layout.mode);
|
||||
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper');
|
||||
const isMixHorizontalMode = computed(() =>
|
||||
['top-hybrid-sidebar-first', 'top-hybrid-header-first'].includes(layoutMode.value)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.layout.footer.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.layout.footer.visible')">
|
||||
<NSwitch v-model:value="themeStore.footer.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
v-if="themeStore.footer.visible && isWrapperScrollMode"
|
||||
key="2"
|
||||
:label="$t('theme.layout.footer.fixed')"
|
||||
>
|
||||
<NSwitch v-model:value="themeStore.footer.fixed" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.footer.visible" key="3" :label="$t('theme.layout.footer.height')">
|
||||
<NInputNumber v-model:value="themeStore.footer.height" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
v-if="themeStore.footer.visible && isMixHorizontalMode"
|
||||
key="4"
|
||||
:label="$t('theme.layout.footer.right')"
|
||||
>
|
||||
<NSwitch v-model:value="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>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'HeaderSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.layout.header.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.layout.header.height')">
|
||||
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem key="2" :label="$t('theme.layout.header.breadcrumb.visible')">
|
||||
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem
|
||||
v-if="themeStore.header.breadcrumb.visible"
|
||||
key="3"
|
||||
:label="$t('theme.layout.header.breadcrumb.showIcon')"
|
||||
>
|
||||
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
|
||||
</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>
|
||||
@@ -0,0 +1,84 @@
|
||||
<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>
|
||||
<NDivider>{{ $t('theme.layout.layoutMode.title') }}</NDivider>
|
||||
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
|
||||
<template #vertical>
|
||||
<div class="layout-sider h-full w-18px !bg-primary"></div>
|
||||
<div class="vertical-wrapper">
|
||||
<div class="layout-header bg-primary-200"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #vertical-mix>
|
||||
<div class="layout-sider h-full w-8px !bg-primary"></div>
|
||||
<div class="layout-sider h-full w-16px !bg-primary-300"></div>
|
||||
<div class="vertical-wrapper">
|
||||
<div class="layout-header bg-primary-200"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #vertical-hybrid-header-first>
|
||||
<div class="layout-sider h-full w-8px !bg-primary"></div>
|
||||
<div class="layout-sider h-full w-16px !bg-primary-300"></div>
|
||||
<div class="vertical-wrapper">
|
||||
<div class="layout-header bg-primary"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #horizontal>
|
||||
<div class="layout-header !bg-primary"></div>
|
||||
<div class="horizontal-wrapper">
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #top-hybrid-sidebar-first>
|
||||
<div class="layout-header !bg-primary-300"></div>
|
||||
<div class="horizontal-wrapper">
|
||||
<div class="layout-sider w-18px !bg-primary"></div>
|
||||
<div class="layout-main"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #top-hybrid-header-first>
|
||||
<div class="layout-header bg-primary"></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 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>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'SiderSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const layoutMode = computed(() => themeStore.layout.mode);
|
||||
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid'));
|
||||
const isHybridLayoutMode = computed(() => layoutMode.value.includes('hybrid'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.layout.sider.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem v-if="layoutMode === 'vertical'" key="1" :label="$t('theme.layout.sider.width')">
|
||||
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="layoutMode === 'vertical'" key="2" :label="$t('theme.layout.sider.collapsedWidth')">
|
||||
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isMixLayoutMode" key="3" :label="$t('theme.layout.sider.mixWidth')">
|
||||
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isMixLayoutMode" key="4" :label="$t('theme.layout.sider.mixCollapsedWidth')">
|
||||
<NInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')">
|
||||
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="isHybridLayoutMode" key="6" :label="$t('theme.layout.sider.autoSelectFirstMenu')">
|
||||
<template #suffix>
|
||||
<IconTooltip :desc="$t('theme.layout.sider.autoSelectFirstMenuTip')" />
|
||||
</template>
|
||||
<NSwitch v-model:value="themeStore.sider.autoSelectFirstMenu" />
|
||||
</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>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { themeTabModeOptions } from '@/constants/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import { $t } from '@/locales';
|
||||
import SettingItem from '../../../components/setting-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'TabSettings'
|
||||
});
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.layout.tab.title') }}</NDivider>
|
||||
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
|
||||
<SettingItem key="1" :label="$t('theme.layout.tab.visible')">
|
||||
<NSwitch v-model:value="themeStore.tab.visible" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="2" :label="$t('theme.layout.tab.cache')">
|
||||
<template #suffix>
|
||||
<IconTooltip :desc="$t('theme.layout.tab.cacheTip')" />
|
||||
</template>
|
||||
<NSwitch v-model:value="themeStore.tab.cache" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="3" :label="$t('theme.layout.tab.height')">
|
||||
<NInputNumber v-model:value="themeStore.tab.height" size="small" :step="1" class="w-120px" />
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="4" :label="$t('theme.layout.tab.mode.title')">
|
||||
<NSelect
|
||||
v-model:value="themeStore.tab.mode"
|
||||
:options="translateOptions(themeTabModeOptions)"
|
||||
size="small"
|
||||
class="w-120px"
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem v-if="themeStore.tab.visible" key="5" :label="$t('theme.layout.tab.closeByMiddleClick')">
|
||||
<template #suffix>
|
||||
<IconTooltip :desc="$t('theme.layout.tab.closeByMiddleClickTip')" />
|
||||
</template>
|
||||
<NSwitch v-model:value="themeStore.tab.closeTabByMiddleClick" />
|
||||
</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>
|
||||
15
src/layouts/modules/theme-drawer/modules/preset/index.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import ThemePreset from './modules/theme-preset.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PresetSettings'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<ThemePreset />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { defu } from 'defu';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { themeSettings } from '@/theme/settings';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemePreset'
|
||||
});
|
||||
|
||||
type ThemePreset = Pick<
|
||||
App.Theme.ThemeSetting,
|
||||
| 'themeScheme'
|
||||
| 'grayscale'
|
||||
| 'colourWeakness'
|
||||
| 'recommendColor'
|
||||
| 'themeColor'
|
||||
| 'themeRadius'
|
||||
| 'otherColor'
|
||||
| 'isInfoFollowPrimary'
|
||||
| 'layout'
|
||||
| 'page'
|
||||
| 'header'
|
||||
| 'tab'
|
||||
| 'fixedHeaderAndTab'
|
||||
| 'sider'
|
||||
| 'footer'
|
||||
| 'watermark'
|
||||
| 'tokens'
|
||||
> & {
|
||||
name: string;
|
||||
desc: string;
|
||||
i18nkey?: string;
|
||||
version: string;
|
||||
/** Optional NaiveUI theme overrides */
|
||||
naiveui?: App.Theme.NaiveUIThemeOverride;
|
||||
};
|
||||
|
||||
const presetModules = import.meta.glob('@/theme/preset/*.json', { eager: true, import: 'default' });
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
// Extract preset data
|
||||
const presets = computed(() =>
|
||||
Object.entries(presetModules)
|
||||
.map(([path, presetData]) => {
|
||||
const fileName = path.split('/').pop()?.replace('.json', '') || '';
|
||||
return {
|
||||
id: fileName,
|
||||
...(presetData as ThemePreset)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.name === 'default') return -1;
|
||||
if (b.name === 'default') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
);
|
||||
|
||||
const getPresetName = (preset: ThemePreset): string => {
|
||||
if (!preset.i18nkey) return preset.name;
|
||||
try {
|
||||
const key = `${preset.i18nkey}.name` as App.I18n.I18nKey;
|
||||
const translated = $t(key);
|
||||
return translated !== key ? translated : preset.name;
|
||||
} catch {
|
||||
return preset.name;
|
||||
}
|
||||
};
|
||||
|
||||
const getPresetDesc = (preset: ThemePreset): string => {
|
||||
if (!preset.i18nkey) return preset.desc;
|
||||
try {
|
||||
const key = `${preset.i18nkey}.desc` as App.I18n.I18nKey;
|
||||
const translated = $t(key);
|
||||
return translated !== key ? translated : preset.desc;
|
||||
} catch {
|
||||
return preset.desc;
|
||||
}
|
||||
};
|
||||
|
||||
const applyPreset = (preset: ThemePreset): void => {
|
||||
const mergedPreset = defu(preset, themeSettings);
|
||||
const { themeScheme, grayscale, colourWeakness, layout, watermark, naiveui, ...rest } = mergedPreset;
|
||||
themeStore.setThemeScheme(themeScheme);
|
||||
themeStore.setGrayscale(grayscale);
|
||||
themeStore.setColourWeakness(colourWeakness);
|
||||
themeStore.setThemeLayout(layout.mode);
|
||||
themeStore.setWatermarkEnableUserName(watermark.enableUserName);
|
||||
themeStore.setWatermarkEnableTime(watermark.enableTime);
|
||||
|
||||
Object.assign(themeStore, {
|
||||
...rest,
|
||||
layout: { ...themeStore.layout, scrollMode: layout.scrollMode },
|
||||
page: { ...rest.page },
|
||||
header: { ...rest.header },
|
||||
tab: { ...rest.tab },
|
||||
sider: { ...rest.sider },
|
||||
footer: { ...rest.footer },
|
||||
watermark: { ...watermark },
|
||||
tokens: { ...rest.tokens }
|
||||
});
|
||||
|
||||
// Apply NaiveUI theme overrides if present
|
||||
themeStore.setNaiveThemeOverrides(naiveui);
|
||||
|
||||
window.$message?.success($t('theme.appearance.preset.applySuccess'));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDivider>{{ $t('theme.appearance.preset.title') }}</NDivider>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="preset in presets"
|
||||
:key="preset.id"
|
||||
class="border border-primary/10 rounded-lg border-solid bg-white/5 p-3 backdrop-blur-10 transition-all duration-300 hover:(shadow-md -translate-y-0.5)"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="min-w-0 w-full flex flex-1 items-center justify-between gap-2">
|
||||
<h5 class="m-0 truncate text-sm text-primary font-600">
|
||||
{{ getPresetName(preset) }}
|
||||
</h5>
|
||||
<NBadge :value="`v${preset.version}`" type="info" size="small" class="flex-shrink-0 opacity-80" />
|
||||
</div>
|
||||
<NButton type="primary" size="tiny" ghost round class="ml-2 flex-shrink-0" @click="applyPreset(preset)">
|
||||
{{ $t('theme.appearance.preset.apply') }}
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<p class="line-clamp-2 mb-3 text-xs text-gray-500 leading-4">{{ getPresetDesc(preset) }}</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-1">
|
||||
<div
|
||||
v-for="(color, key) in { primary: preset.themeColor, ...preset.otherColor }"
|
||||
:key="key"
|
||||
class="h-3 w-3 cursor-pointer border border-white/30 rounded-full transition-transform hover:scale-110"
|
||||
:style="{ backgroundColor: color }"
|
||||
:class="{ 'ring-1 ring-primary/50': key === 'primary' }"
|
||||
:title="key"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="text-lg">
|
||||
{{ preset.themeScheme === 'dark' ? '🌙' : '☀️' }}
|
||||
</div>
|
||||
<div class="text-lg">
|
||||
{{ preset.grayscale ? '🎨' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
20
src/locales/dayjs.ts
Normal 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]);
|
||||
}
|
||||