feat(projects): 1.0 beta

This commit is contained in:
Soybean
2023-11-17 08:45:00 +08:00
parent 1ea4817f6a
commit e918a2c0f5
499 changed files with 15918 additions and 24708 deletions

View File

@ -1,26 +1,40 @@
<template>
<n-config-provider
:theme="theme.naiveTheme"
:theme-overrides="theme.naiveThemeOverrides"
:locale="zhCN"
:date-locale="dateZhCN"
class="h-full"
>
<naive-provider>
<router-view />
</naive-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { dateZhCN, zhCN } from 'naive-ui';
import { subscribeStore, useThemeStore } from '@/store';
import { useGlobalEvents } from '@/composables';
import { computed } from 'vue';
import { NConfigProvider, darkTheme } from 'naive-ui';
import { useAppStore } from './store/modules/app';
import { useThemeStore } from './store/modules/theme';
import { naiveLocales, naiveDateLocales } from './locales/naive';
const theme = useThemeStore();
defineOptions({
name: 'App'
});
subscribeStore();
useGlobalEvents();
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];
});
</script>
<template>
<NConfigProvider
:theme="naiveDarkTheme"
:theme-overrides="themeStore.naiveTheme"
:locale="naiveLocale"
:date-locale="naiveDateLocale"
class="h-full"
>
<AppProvider>
<RouterView class="bg-layout" />
</AppProvider>
</NConfigProvider>
</template>
<style scoped></style>

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h160v160H0V0z" fill="currentColor"/><path d="M94.322 51.888A69.12 69.12 0 0187.806 80.9a1.732 1.732 0 00.191 2.014c6.124 8.338 13.677 14.894 23.356 18.821a46.564 46.564 0 0017.273 3.414 29.101 29.101 0 003.364-.252 6.245 6.245 0 017.051 5.156 6.112 6.112 0 01-5.187 7.19 50.758 50.758 0 01-18.19-1.007c-15.964-3.686-28.2-12.84-37.709-25.88a2.165 2.165 0 00-2.246-1.098c-14.1 1.38-26.357 6.475-35.754 17.331a38.721 38.721 0 00-6.275 9.808 6.255 6.255 0 01-8.229 3.444 6.184 6.184 0 01-3.293-8.258 49.662 49.662 0 019.699-14.722c10.636-11.52 23.97-17.663 39.37-19.677a14.06 14.06 0 012.86-.342c1.622.14 2.197-.735 2.75-2.014a54.752 54.752 0 004.865-23.463 44.302 44.302 0 00-8.057-25.175 6.152 6.152 0 01-.655-6.506 6.043 6.043 0 015.318-3.564 6.386 6.386 0 015.7 3.02 53.98 53.98 0 017.222 14.38 59.734 59.734 0 013.092 18.368z" fill="#fff"/><path d="M47.257 119.468a6.04 6.04 0 011.36-3.907 38.165 38.165 0 0122.66-14.098 6.124 6.124 0 016.699 2.487 6.223 6.223 0 01-3.868 9.698 26.276 26.276 0 00-15.823 9.838 6.245 6.245 0 01-11.028-4.028v.01zm77.935-26.01a34.908 34.908 0 01-9.89-2.498 35.717 35.717 0 01-14.756-10.523 6.233 6.233 0 012.861-10 5.832 5.832 0 016.486 1.742 26.986 26.986 0 0016.628 8.912 6.042 6.042 0 015.036 5.58 6.253 6.253 0 01-4.32 6.504 6.588 6.588 0 01-2.045.282zM69.817 53.65a33.69 33.69 0 01-2.286 12.607 6.255 6.255 0 01-11.018 1.007 6.132 6.132 0 01-.655-5.438 26.178 26.178 0 00-.534-18.377 6.256 6.256 0 0111.572-4.753 40.515 40.515 0 012.921 14.954z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,97 +0,0 @@
<template>
<div v-if="reloadFlag" class="relative">
<slot></slot>
<div v-show="showPlaceholder" class="absolute-lt w-full h-full" :class="placeholderClass">
<div v-show="loading" class="absolute-center">
<n-spin :show="true" :size="loadingSize" />
</div>
<div v-show="isEmpty" class="absolute-center">
<div class="relative">
<icon-local-empty-data :class="iconClass" />
<p class="absolute-lb w-full text-center" :class="descClass">{{ emptyDesc }}</p>
</div>
</div>
<div v-show="!network" class="absolute-center">
<div class="relative" :class="{ 'cursor-pointer': showNetworkReload }" @click="handleReload">
<icon-local-network-error :class="iconClass" />
<p class="absolute-lb w-full text-center" :class="descClass">{{ networkErrorDesc }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onUnmounted, watch } from 'vue';
import { NETWORK_ERROR_MSG } from '@/config';
import { useBoolean } from '@/hooks';
defineOptions({ name: 'LoadingEmptyWrapper' });
interface Props {
/** 是否加载 */
loading: boolean;
/** 是否为空 */
empty?: boolean;
/** 加载图标的大小 */
loadingSize?: 'small' | 'medium' | 'large';
/** 中间占位符的class */
placeholderClass?: string;
/** 空数据描述文本 */
emptyDesc?: string;
/** 图标的class */
iconClass?: string;
/** 描述文本的class */
descClass?: string;
/** 显示网络异常的重试点击按钮 */
showNetworkReload?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
empty: false,
loadingSize: 'medium',
placeholderClass: 'bg-white dark:bg-dark transition-background-color duration-300 ease-in-out',
emptyDesc: '暂无数据',
iconClass: 'text-320px text-primary',
descClass: 'text-16px text-#666',
showNetworkReload: false
});
// 网络状态
const { bool: network, setBool: setNetwork } = useBoolean(window.navigator.onLine);
const { bool: reloadFlag, setBool: setReload } = useBoolean(true);
// 数据是否为空
const isEmpty = computed(() => props.empty && !props.loading && network.value);
const showPlaceholder = computed(() => props.loading || isEmpty.value || !network.value);
const networkErrorDesc = computed(() =>
props.showNetworkReload ? `${NETWORK_ERROR_MSG}, 点击重试` : NETWORK_ERROR_MSG
);
function handleReload() {
if (!props.showNetworkReload) return;
setReload(false);
nextTick(() => {
setReload(true);
});
}
const stopHandle = watch(
() => props.loading,
newValue => {
// 结束加载判断一下网络状态
if (!newValue) {
setNetwork(window.navigator.onLine);
}
}
);
onUnmounted(() => {
stopHandle();
});
</script>
<style scoped></style>

View File

@ -1,50 +0,0 @@
<template>
<div class="w-full text-14px">
<n-checkbox v-model:checked="checked">我已经仔细阅读并接受</n-checkbox>
<n-button :text="true" type="primary" @click="handleClickProtocol">用户协议</n-button>
<n-button :text="true" type="primary" @click="handleClickPolicy">隐私权政策</n-button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'LoginAgreement' });
interface Props {
/** 是否勾选 */
value?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
value: true
});
interface Emits {
(e: 'update:value', value: boolean): void;
/** 点击协议 */
(e: 'click-protocol'): void;
/** 点击隐私政策 */
(e: 'click-policy'): void;
}
const emit = defineEmits<Emits>();
const checked = computed({
get() {
return props.value;
},
set(newValue: boolean) {
emit('update:value', newValue);
}
});
function handleClickProtocol() {
emit('click-protocol');
}
function handleClickPolicy() {
emit('click-policy');
}
</script>
<style scoped></style>

View File

@ -1,6 +1,30 @@
<script setup lang="ts">
import { getRgbOfColor } from '@sa/utils';
import { $t } from '@/locales';
import { localStg } from '@/utils/storage';
const loadingClasses = [
'left-0 top-0',
'left-0 bottom-0 animate-delay-500',
'right-0 top-0 animate-delay-1000',
'right-0 bottom-0 animate-delay-1500'
];
function addThemeColorCssVars() {
const themeColor = localStg.get('themeColor') || '#646cff';
const { r, g, b } = getRgbOfColor(themeColor);
const cssVars = `--primary-color: ${r} ${g} ${b}`;
document.documentElement.style.cssText = cssVars;
}
addThemeColorCssVars();
</script>
<template>
<div class="fixed-center flex-col">
<system-logo class="text-128px text-primary" />
<SystemLogo class="text-128px text-primary" />
<div class="w-56px h-56px my-36px">
<div class="relative h-full animate-spin">
<div
@ -15,29 +39,4 @@
</div>
</template>
<script setup lang="ts">
import { sessionStg, getRgbOfColor } from '@/utils';
import { $t } from '@/locales';
import themeSettings from '@/settings/theme.json';
const loadingClasses = [
'left-0 top-0',
'left-0 bottom-0 animate-delay-500',
'right-0 top-0 animate-delay-1000',
'right-0 bottom-0 animate-delay-1500'
];
function addThemeColorCssVars() {
const defaultColor = themeSettings.themeColor;
const themeColor = sessionStg.get('themeColor') || defaultColor;
const { r, g, b } = getRgbOfColor(themeColor);
const cssVars = `--primary-color: ${r},${g},${b}`;
document.documentElement.style.cssText = cssVars;
}
addThemeColorCssVars();
</script>
<style scoped></style>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { defineComponent, createTextVNode } 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();
},
render() {
return createTextVNode();
}
});
</script>
<template>
<NLoadingBarProvider>
<NDialogProvider>
<NNotificationProvider>
<NMessageProvider>
<ContextHolder />
<slot></slot>
</NMessageProvider>
</NNotificationProvider>
</NDialogProvider>
</NLoadingBarProvider>
</template>
<style scoped></style>

View File

@ -1,12 +1,3 @@
<template>
<div
class="dark:bg-dark dark:text-white dark:text-opacity-82 transition-all"
:class="inverted ? 'bg-#001428 text-white' : 'bg-white text-#333639'"
>
<slot></slot>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'DarkModeContainer' });
@ -14,9 +5,13 @@ interface Props {
inverted?: boolean;
}
withDefaults(defineProps<Props>(), {
inverted: false
});
defineProps<Props>();
</script>
<template>
<div class="bg-container text-base_text transition-300" :class="{ 'bg-inverted text-#1f1f1f': inverted }">
<slot></slot>
</div>
</template>
<style scoped></style>

View File

@ -1,89 +0,0 @@
<template>
<div class="flex-center text-18px cursor-pointer" @click="handleSwitch">
<icon-mdi-moon-waning-crescent v-if="darkMode" />
<icon-mdi-white-balance-sunny v-else />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
defineOptions({ name: 'DarkModeSwitch' });
interface Props {
/** 暗黑模式 */
dark?: boolean;
/** 自定义暗黑模式动画过渡 */
customizeTransition?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
dark: false
});
interface Emits {
(e: 'update:dark', darkMode: boolean): void;
}
const emit = defineEmits<Emits>();
const darkMode = computed({
get() {
return props.dark;
},
set(newValue: boolean) {
emit('update:dark', newValue);
}
});
async function handleSwitch(event: MouseEvent) {
const x = event.clientX;
const y = event.clientY;
if (!props.customizeTransition || !document.startViewTransition) {
darkMode.value = !darkMode.value;
return;
}
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y));
const transition = document.startViewTransition(() => {
darkMode.value = !darkMode.value;
});
await transition.ready;
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`];
document.documentElement.animate(
{
clipPath: darkMode.value ? clipPath : [...clipPath].reverse()
},
{
duration: 300,
easing: 'ease-in',
pseudoElement: darkMode.value ? '::view-transition-new(root)' : '::view-transition-old(root)'
}
);
}
</script>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 9999;
}
::view-transition-new(root) {
z-index: 1;
}
.dark::view-transition-old(root) {
z-index: 1;
}
.dark::view-transition-new(root) {
z-index: 9999;
}
</style>

View File

@ -1,31 +1,41 @@
<template>
<div class="flex-col-center gap-24px min-h-520px wh-full overflow-hidden">
<div class="flex text-400px text-primary">
<icon-local-no-permission v-if="type === '403'" />
<icon-local-not-found v-if="type === '404'" />
<icon-local-service-error v-if="type === '500'" />
</div>
<router-link :to="{ name: routeHomePath }">
<n-button type="primary">回到首页</n-button>
</router-link>
</div>
</template>
<script lang="ts" setup>
import { routeName } from '@/router';
import { computed } from 'vue';
import { $t } from '@/locales';
defineOptions({ name: 'ExceptionBase' });
type ExceptionType = '403' | '404' | '500';
interface Props {
/** 异常类型 403 404 500 */
/**
* exception type
* - 403: no permission
* - 404: not found
* - 500: service error
*/
type: ExceptionType;
}
defineProps<Props>();
const props = defineProps<Props>();
const routeHomePath = routeName('root');
const iconMap: Record<ExceptionType, string> = {
'403': 'no-permission',
'404': 'not-found',
'500': 'service-error'
};
const icon = computed(() => iconMap[props.type]);
</script>
<template>
<div class="flex-vertical-center gap-24px min-h-520px wh-full overflow-hidden">
<div class="flex text-400px text-primary">
<SvgIcon :local-icon="icon" />
</div>
<RouterLink to="/">
<NButton type="primary">{{ $t('common.backToHome') }}</NButton>
</RouterLink>
</div>
</template>
<style scoped></style>

View File

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

View File

@ -1,48 +0,0 @@
<template>
<div v-if="showTooltip">
<n-tooltip :placement="placement" trigger="hover">
<template #trigger>
<div class="flex-center h-full cursor-pointer dark:hover:bg-#333" :class="contentClassName">
<slot></slot>
</div>
</template>
{{ tooltipContent }}
</n-tooltip>
</div>
<div v-else class="flex-center cursor-pointer dark:hover:bg-#333" :class="contentClassName">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import type { PopoverPlacement } from 'naive-ui';
defineOptions({ name: 'HoverContainer' });
interface Props {
/** tooltip显示文本 */
tooltipContent?: string;
/** tooltip的位置 */
placement?: PopoverPlacement;
/** class类 */
contentClass?: string;
/** 反转模式下 */
inverted?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
tooltipContent: '',
placement: 'bottom',
contentClass: '',
inverted: false
});
const showTooltip = computed(() => Boolean(props.tooltipContent));
const contentClassName = computed(
() => `${props.contentClass} ${props.inverted ? 'hover:bg-primary' : 'hover:bg-#f6f6f6'}`
);
</script>
<style scoped></style>

View File

@ -0,0 +1,55 @@
<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 emits = defineEmits<Emits>();
const tooltipContent = computed(() => {
if (!props.showTooltip) return '';
return $t('icon.lang');
});
function changeLang(lang: App.I18n.LangType) {
emits('changeLang', lang);
}
</script>
<template>
<NDropdown :value="lang" :options="langOptions" trigger="hover" @select="changeLang">
<div>
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
<SvgIcon icon="heroicons:language" />
</ButtonIcon>
</div>
</NDropdown>
</template>
<style scoped></style>

View File

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

View File

@ -1,38 +0,0 @@
<template>
<n-loading-bar-provider>
<n-dialog-provider>
<n-notification-provider>
<n-message-provider>
<slot></slot>
<naive-provider-content />
</n-message-provider>
</n-notification-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</template>
<script setup lang="ts">
import { defineComponent, h } from 'vue';
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui';
defineOptions({ name: 'NaiveProvider' });
// 挂载naive组件的方法至window, 以便在路由钩子函数和请求函数里面调用
function registerNaiveTools() {
window.$loadingBar = useLoadingBar();
window.$dialog = useDialog();
window.$message = useMessage();
window.$notification = useNotification();
}
const NaiveProviderContent = defineComponent({
name: 'NaiveProviderContent',
setup() {
registerNaiveTools();
},
render() {
return h('div');
}
});
</script>
<style scoped></style>

View File

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

View File

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

View File

@ -1,19 +1,9 @@
<template>
<icon-local-logo-fill v-if="fill" />
<icon-local-logo v-else />
</template>
<script lang="ts" setup>
defineOptions({ name: 'SystemLogo' });
interface Props {
/** logo是否填充 */
fill?: boolean;
}
withDefaults(defineProps<Props>(), {
fill: false
});
</script>
<template>
<icon-local-logo />
</template>
<style scoped></style>

View File

@ -0,0 +1,62 @@
<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>

View File

@ -1,11 +1,3 @@
<template>
<div ref="bsWrap" class="h-full text-left">
<div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
@ -15,15 +7,21 @@ import type { Options } from '@better-scroll/core';
defineOptions({ name: 'BetterScroll' });
interface Props {
/** better-scroll的配置: https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html */
/**
* BetterScroll options
* @link https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html
*/
options: Options;
}
const props = defineProps<Props>();
const bsWrap = ref<HTMLElement>();
const instance = ref<BScroll>();
const bsContent = ref<HTMLElement>();
const { width: wrapWidth } = useElementSize(bsWrap);
const { width, height } = useElementSize(bsContent);
const instance = ref<BScroll>();
const isScrollY = computed(() => Boolean(props.options.scrollY));
function initBetterScroll() {
@ -31,13 +29,9 @@ function initBetterScroll() {
instance.value = new BScroll(bsWrap.value, props.options);
}
// 滚动元素发生变化刷新BS
const { width: wrapWidth } = useElementSize(bsWrap);
const { width, height } = useElementSize(bsContent);
// refresh BS when scroll element size changed
watch([() => wrapWidth.value, () => width.value, () => height.value], () => {
if (instance.value) {
instance.value.refresh();
}
instance.value?.refresh();
});
onMounted(() => {
@ -47,4 +41,12 @@ onMounted(() => {
defineExpose({ instance });
</script>
<template>
<div ref="bsWrap" class="h-full text-left">
<div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
<slot></slot>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core';
import type { PopoverPlacement } from 'naive-ui';
defineOptions({
name: 'ButtonIcon',
inheritAttrs: false
});
interface Props {
/**
* button class
*/
class?: string;
/**
* iconify icon name
*/
icon?: string;
/**
* tooltip content
*/
tooltipContent?: string;
/**
* tooltip placement
*/
tooltipPlacement?: PopoverPlacement;
}
const props = withDefaults(defineProps<Props>(), {
class: 'h-36px text-icon',
icon: '',
tooltipContent: '',
tooltipPlacement: 'bottom'
});
interface ButtonProps {
className: string;
}
const [DefineButton, Button] = createReusableTemplate<ButtonProps>();
const cls = computed(() => {
let clsStr = props.class;
if (!clsStr.includes('h-')) {
clsStr += ' h-36px';
}
if (!clsStr.includes('text-')) {
clsStr += ' text-icon';
}
return clsStr;
});
</script>
<template>
<!-- define component: Button -->
<DefineButton v-slot="{ $slots, className }">
<NButton quaternary :class="className">
<div class="flex-center gap-8px">
<component :is="$slots.default" />
</div>
</NButton>
</DefineButton>
<!-- template -->
<NTooltip v-if="tooltipContent" :placement="tooltipPlacement">
<template #trigger>
<Button :class-name="cls" v-bind="$attrs">
<slot>
<SvgIcon :icon="icon" />
</slot>
</Button>
</template>
{{ tooltipContent }}
</NTooltip>
<Button v-else :class-name="cls" v-bind="$attrs">
<slot>
<SvgIcon :icon="icon" />
</slot>
</Button>
</template>
<style scoped></style>

View File

@ -1,116 +0,0 @@
<template>
<span>{{ value }}</span>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch, watchEffect } from 'vue';
import { TransitionPresets, useTransition } from '@vueuse/core';
import { isNumber } from '@/utils';
defineOptions({ name: 'CountTo' });
type TransitionKey = keyof typeof TransitionPresets;
interface Props {
/** 初始值 */
startValue?: number;
/** 结束值 */
endValue?: number;
/** 动画时长 */
duration?: number;
/** 自动动画 */
autoplay?: boolean;
/** 进制 */
decimals?: number;
/** 前缀 */
prefix?: string;
/** 后缀 */
suffix?: string;
/** 分割符号 */
separator?: string;
/** 小数点 */
decimal?: string;
/** 使用缓冲动画函数 */
useEasing?: boolean;
/** 缓冲动画函数类型 */
transition?: TransitionKey;
}
const props = withDefaults(defineProps<Props>(), {
startValue: 0,
endValue: 2021,
duration: 1500,
autoplay: true,
decimals: 0,
prefix: '',
suffix: '',
separator: ',',
decimal: '.',
useEasing: true,
transition: 'linear'
});
interface Emits {
(e: 'on-started'): void;
(e: 'on-finished'): void;
}
const emit = defineEmits<Emits>();
const source = ref(props.startValue);
let outputValue = useTransition(source);
const value = computed(() => formatNumber(outputValue.value));
const disabled = ref(false);
function run() {
outputValue = useTransition(source, {
disabled,
duration: props.duration,
onStarted: () => emit('on-started'),
onFinished: () => emit('on-finished'),
...(props.useEasing ? { transition: TransitionPresets[props.transition] } : {})
});
}
function start() {
run();
source.value = props.endValue;
}
function formatNumber(num: number | string) {
if (num !== 0 && !num) {
return '';
}
const { decimals, decimal, separator, suffix, prefix } = props;
let number = 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 && !isNumber(separator)) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, `$1${separator}$2`);
}
}
return prefix + x1 + x2 + suffix;
}
watch([() => props.startValue, () => props.endValue], () => {
if (props.autoplay) {
start();
}
});
watchEffect(() => {
source.value = props.startValue;
});
onMounted(() => {
if (props.autoplay) {
start();
}
});
</script>
<style scoped></style>

View File

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

View File

@ -1,77 +0,0 @@
<template>
<n-popover placement="bottom-end" trigger="click">
<template #trigger>
<n-input v-model:value="modelValue" readonly placeholder="点击选择图标">
<template #suffix>
<svg-icon :icon="selectedIcon" class="text-30px p-5px" />
</template>
</n-input>
</template>
<template #header>
<n-input v-model:value="searchValue" placeholder="搜索图标"></n-input>
</template>
<div v-if="iconsList.length > 0" class="grid grid-cols-9 h-auto overflow-auto">
<span v-for="iconItem in iconsList" :key="iconItem" @click="handleChange(iconItem)">
<svg-icon
:icon="iconItem"
class="border-1px border-#d9d9d9 text-30px m-2px p-5px cursor-pointer"
:class="{ 'border-primary': modelValue === iconItem }"
/>
</span>
</div>
<n-empty v-else class="w-306px" description="你什么也找不到" />
</n-popover>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
defineOptions({ name: 'IconSelect' });
interface Props {
/** 选中的图标 */
value: string;
/** 图标列表 */
icons: string[];
/** 未选中图标 */
emptyIcon?: string;
}
const props = withDefaults(defineProps<Props>(), {
emptyIcon: 'mdi:apps'
});
interface Emits {
(e: 'update:value', val: string): void;
}
const emit = defineEmits<Emits>();
const modelValue = computed({
get() {
return props.value;
},
set(val: string) {
emit('update:value', val);
}
});
const selectedIcon = computed(() => modelValue.value || props.emptyIcon);
const searchValue = ref('');
const iconsList = computed(() => props.icons.filter(v => v.includes(searchValue.value)));
function handleChange(iconItem: string) {
modelValue.value = iconItem;
}
</script>
<style lang="scss" scoped>
:deep(.n-input-wrapper) {
padding-right: 0;
}
:deep(.n-input__suffix) {
border: 1px solid #d9d9d9;
}
</style>

View File

@ -1,42 +0,0 @@
<template>
<div>
<canvas ref="domRef" width="152" height="40" class="cursor-pointer" @click="getImgCode"></canvas>
</div>
</template>
<script setup lang="ts">
import { watch } from 'vue';
import { useImageVerify } from '@/hooks';
defineOptions({ name: 'ImageVerify' });
interface Props {
code?: string;
}
const props = withDefaults(defineProps<Props>(), {
code: ''
});
interface Emits {
(e: 'update:code', code: string): void;
}
const emit = defineEmits<Emits>();
const { domRef, imgCode, setImgCode, getImgCode } = useImageVerify();
watch(
() => props.code,
newValue => {
setImgCode(newValue);
}
);
watch(imgCode, newValue => {
emit('update:code', newValue);
});
defineExpose({ getImgCode });
</script>
<style scoped></style>

View File

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

View File

@ -1,14 +1,3 @@
<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>
<script setup lang="ts">
import { computed, useAttrs } from 'vue';
import { Icon } from '@iconify/vue';
@ -16,14 +5,18 @@ import { Icon } from '@iconify/vue';
defineOptions({ name: 'SvgIcon' });
/**
* 图标组件
* - 支持iconify和本地svg图标
* - 同时传递了icon和localIconlocalIcon会优先渲染
* 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;
/** 本地svg的文件名 */
/**
* local svg icon name
*/
localIcon?: string;
}
@ -46,8 +39,21 @@ const symbolId = computed(() => {
return `#${prefix}-${icon}`;
});
/** 渲染本地icon */
/**
* if localIcon is passed, render localIcon first
*/
const renderLocalIcon = computed(() => props.localIcon || !props.icon);
</script>
<template>
<template v-if="renderLocalIcon">
<svg aria-hidden="true" width="1em" height="1em" v-bind="bindAttrs">
<use :xlink:href="symbolId" fill="currentColor" />
</svg>
</template>
<template v-else>
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" />
</template>
</template>
<style scoped></style>

View File

@ -0,0 +1,61 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { getColorPalette } from '@sa/utils';
interface Props {
/**
* theme color
*/
themeColor: string;
}
const props = defineProps<Props>();
const lightColor = computed(() => getColorPalette(props.themeColor, 3));
const darkColor = computed(() => getColorPalette(props.themeColor, 6));
</script>
<template>
<div class="absolute-lt z-1 wh-full overflow-hidden">
<div class="absolute -right-300px -top-900px <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 -left-200px -bottom-400px <sm:(-left-100px -bottom-760px)">
<svg height="896" width="967.8852157128662">
<defs>
<path
id="path-2"
opacity="1"
fill-rule="evenodd"
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
/>
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
</linearGradient>
</defs>
<g opacity="1">
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
</g>
</svg>
</div>
</div>
</template>
<style scoped></style>

View File

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

View File

@ -1,174 +0,0 @@
import { nextTick, effectScope, onScopeDispose, ref, watch } from 'vue';
import type { ComputedRef, Ref } from 'vue';
import * as echarts from 'echarts/core';
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts';
import type {
BarSeriesOption,
GaugeSeriesOption,
LineSeriesOption,
PictorialBarSeriesOption,
PieSeriesOption,
RadarSeriesOption,
ScatterSeriesOption
} from 'echarts/charts';
import {
DatasetComponent,
GridComponent,
LegendComponent,
TitleComponent,
ToolboxComponent,
TooltipComponent,
TransformComponent
} from 'echarts/components';
import type {
DatasetComponentOption,
GridComponentOption,
LegendComponentOption,
TitleComponentOption,
ToolboxComponentOption,
TooltipComponentOption
} from 'echarts/components';
import { LabelLayout, UniversalTransition } from 'echarts/features';
import { CanvasRenderer } from 'echarts/renderers';
import { useElementSize } from '@vueuse/core';
import { useThemeStore } from '@/store';
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
]);
/**
* Echarts hooks函数
* @param options - 图表配置
* @param renderFun - 图表渲染函数(例如:图表监听函数)
* @description 按需引入图表组件,没注册的组件需要先引入
*/
export function useEcharts(
options: Ref<ECOption> | ComputedRef<ECOption>,
renderFun?: (chartInstance: echarts.ECharts) => void
) {
const theme = useThemeStore();
const domRef = ref<HTMLElement>();
const initialSize = { width: 0, height: 0 };
const { width, height } = useElementSize(domRef, initialSize);
let chart: echarts.ECharts | null = null;
function canRender() {
return initialSize.width > 0 && initialSize.height > 0;
}
function isRendered() {
return Boolean(domRef.value && chart);
}
function update(updateOptions: ECOption) {
if (isRendered()) {
chart?.clear();
chart!.setOption({ ...updateOptions, backgroundColor: 'transparent' });
}
}
async function render() {
if (domRef.value) {
const chartTheme = theme.darkMode ? 'dark' : 'light';
await nextTick();
chart = echarts.init(domRef.value, chartTheme);
if (renderFun) {
renderFun(chart);
}
update(options.value);
}
}
function resize() {
chart?.resize();
}
function destroy() {
chart?.dispose();
}
function updateTheme() {
destroy();
render();
}
const scope = effectScope();
scope.run(() => {
watch([width, height], ([newWidth, newHeight]) => {
initialSize.width = newWidth;
initialSize.height = newHeight;
if (newWidth === 0 && newHeight === 0) {
// 节点被删除 将chart置为空
chart = null;
}
if (canRender()) {
if (!isRendered()) {
render();
} else {
resize();
}
}
});
watch(
options,
newValue => {
update(newValue);
},
{ deep: true }
);
watch(
() => theme.darkMode,
() => {
updateTheme();
}
);
});
onScopeDispose(() => {
destroy();
scope.stop();
});
return {
domRef
};
}

View File

@ -1,34 +0,0 @@
import { effectScope, onScopeDispose, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useEventListener } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useTabStore, useThemeStore } from '@/store';
/** 全局事件 */
export function useGlobalEvents() {
const theme = useThemeStore();
const tab = useTabStore();
const route = useRoute();
const { locale, t } = useI18n();
const scope = effectScope();
/** 页面离开时缓存多页签数据 */
useEventListener(window, 'beforeunload', () => {
theme.cacheThemeSettings();
tab.cacheTabRoutes();
});
scope.run(() => {
// 国际化切换时更新浏览器标签文本
watch(
() => locale.value,
() => {
document.title = route.meta.i18nTitle ? t(route.meta.i18nTitle) : route.meta.title;
}
);
});
onScopeDispose(() => {
scope.stop();
});
}

View File

@ -1,60 +0,0 @@
import { h } from 'vue';
import SvgIcon from '@/components/custom/svg-icon.vue';
/**
* 图标渲染
* - 用于vue的render函数
*/
export const useIconRender = () => {
interface IconConfig {
/**
* 图标名称(iconify图标的名称)
* - 例如mdi-account 或者 mdi:account
*/
icon?: string;
/**
* 本地svg图标文件名(assets/svg-icon文件夹下)
*/
localIcon?: string;
/** 图标颜色 */
color?: string;
/** 图标大小 */
fontSize?: number;
}
interface IconStyle {
color?: string;
fontSize?: string;
}
/**
* 图标渲染
* @param config
* @property icon - 图标名称(iconify图标的名称), 例如mdi-account 或者 mdi:account
* @property localIcon - 本地svg图标文件名(assets/svg-icon文件夹下)
* @property color - 图标颜色
* @property fontSize - 图标大小
*/
const iconRender = (config: IconConfig) => {
const { color, fontSize, icon, localIcon } = config;
const style: IconStyle = {};
if (color) {
style.color = color;
}
if (fontSize) {
style.fontSize = `${fontSize}px`;
}
if (!icon && !localIcon) {
window.console.warn('没有传递图标名称请确保给icon或localIcon传递有效值!');
}
return () => h(SvgIcon, { icon, localIcon, style });
};
return {
iconRender
};
};

View File

@ -1,7 +0,0 @@
export * from './system';
export * from './router';
export * from './layout';
export * from './events';
export * from './echarts';
export * from './icon';
export * from './websocket';

View File

@ -1,84 +0,0 @@
import { computed, watch } from 'vue';
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core';
import { useAppStore, useThemeStore } from '@/store';
type LayoutMode = 'vertical' | 'horizontal';
type LayoutHeaderProps = Record<UnionKey.ThemeLayoutMode, App.GlobalHeaderProps>;
export function useBasicLayout() {
const app = useAppStore();
const theme = useThemeStore();
const breakpoints = useBreakpoints(breakpointsTailwind);
const mode = computed(() => {
const vertical: LayoutMode = 'vertical';
const horizontal: LayoutMode = 'horizontal';
return theme.layout.mode.includes(vertical) ? vertical : horizontal;
});
const isMobile = breakpoints.smaller('sm');
const layoutHeaderProps: LayoutHeaderProps = {
vertical: {
showLogo: false,
showHeaderMenu: false,
showMenuCollapse: true
},
'vertical-mix': {
showLogo: false,
showHeaderMenu: false,
showMenuCollapse: false
},
horizontal: {
showLogo: true,
showHeaderMenu: true,
showMenuCollapse: false
},
'horizontal-mix': {
showLogo: true,
showHeaderMenu: false,
showMenuCollapse: true
}
};
const headerProps = computed(() => layoutHeaderProps[theme.layout.mode]);
const siderVisible = computed(() => theme.layout.mode !== 'horizontal');
const siderWidth = computed(() => {
const { width, mixWidth, mixChildMenuWidth } = theme.sider;
const isVerticalMix = theme.layout.mode === 'vertical-mix';
let w = isVerticalMix ? mixWidth : width;
if (isVerticalMix && app.mixSiderFixed) {
w += mixChildMenuWidth;
}
return w;
});
const siderCollapsedWidth = computed(() => {
const { collapsedWidth, mixCollapsedWidth, mixChildMenuWidth } = theme.sider;
const isVerticalMix = theme.layout.mode === 'vertical-mix';
let w = isVerticalMix ? mixCollapsedWidth : collapsedWidth;
if (isVerticalMix && app.mixSiderFixed) {
w += mixChildMenuWidth;
}
return w;
});
watch(
isMobile,
newValue => {
if (newValue) {
app.setSiderCollapse(true);
}
},
{ immediate: true }
);
return {
mode,
isMobile,
headerProps,
siderVisible,
siderWidth,
siderCollapsedWidth
};
}

View File

@ -1,85 +0,0 @@
import { useRouter } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import { router as globalRouter, routeName } from '@/router';
/**
* 路由跳转
* @param inSetup - 是否在vue页面/组件的setup里面调用在axios里面无法使用useRouter和useRoute
*/
export function useRouterPush(inSetup = true) {
const router = inSetup ? useRouter() : globalRouter;
const route = globalRouter.currentRoute;
/**
* 路由跳转
* @param to - 需要跳转的路由
* @param newTab - 是否在新的浏览器Tab标签打开
*/
function routerPush(to: RouteLocationRaw, newTab = false) {
if (newTab) {
const routerData = router.resolve(to);
window.open(routerData.href, '_blank');
return Promise.resolve();
}
return router.push(to);
}
/** 返回上一级路由 */
function routerBack() {
router.go(-1);
}
/**
* 跳转首页
* @param newTab - 在新的浏览器标签打开
*/
function toHome(newTab = false) {
routerPush({ name: routeName('root') }, newTab);
}
/**
* 跳转登录页面
* @param loginModule - 展示的登录模块
* @param redirectUrl - 重定向地址(登录成功后跳转的地址),默认undefined表示取当前地址为重定向地址
*/
function toLogin(loginModule?: UnionKey.LoginModule, redirectUrl?: string) {
const module: UnionKey.LoginModule = loginModule || 'pwd-login';
const routeLocation: RouteLocationRaw = {
name: routeName('login'),
params: { module }
};
const redirect = redirectUrl || route.value.fullPath;
Object.assign(routeLocation, { query: { redirect } });
routerPush(routeLocation);
}
/**
* 登录页切换其他模块
* @param module - 切换后的登录模块
*/
function toLoginModule(module: UnionKey.LoginModule) {
const { query } = route.value;
routerPush({ name: routeName('login'), params: { module }, query });
}
/**
* 登录成功后跳转重定向的地址
*/
function toLoginRedirect() {
const { query } = route.value;
if (query?.redirect) {
routerPush(query.redirect as string);
} else {
toHome();
}
}
return {
routerPush,
routerBack,
toHome,
toLogin,
toLoginModule,
toLoginRedirect
};
}

View File

@ -1,34 +0,0 @@
import UAParser from 'ua-parser-js';
import { useAuthStore } from '@/store';
import { isArray, isString } from '@/utils';
/** 获取设备信息 */
export function useDeviceInfo() {
const parser = new UAParser();
const result = parser.getResult();
return result;
}
/** 权限判断 */
export function usePermission() {
const auth = useAuthStore();
function hasPermission(permission: Auth.RoleType | Auth.RoleType[]) {
const { userRole } = auth.userInfo;
let has = userRole === 'super';
if (!has) {
if (isArray(permission)) {
has = (permission as Auth.RoleType[]).includes(userRole);
}
if (isString(permission)) {
has = (permission as Auth.RoleType) === userRole;
}
}
return has;
}
return {
hasPermission
};
}

View File

@ -1,50 +0,0 @@
import { io } from 'socket.io-client';
import type { Socket } from 'socket.io-client';
import { useAppStore } from '../store';
type ListenEvents = {
update: (id: string, data: { name: string; age: number }) => void;
};
type EmitEvents = {
update: (id: string, data: { name: string; age: number }) => void;
};
export function useWebsocket() {
const app = useAppStore();
const socket: Socket<ListenEvents, EmitEvents> = (app.socket || io('ws://localhost:8080')) as Socket<
ListenEvents,
EmitEvents
>;
if (!app.socket) {
app.setSocket(socket);
}
function init() {
window.console.log('[socket.io] connecting...');
socket.on('connect', () => {
window.console.log('[socket.io] connected.');
});
socket.on('disconnect', () => {
window.console.log('[socket.io] disconnected.');
});
socket.on('update', (id, data) => {
window.console.log('[socket.io] update', id, data);
});
}
function handleUpdate(id: string, data: { name: string; age: number }) {
socket.emit('update', id, data);
}
init();
return {
handleUpdate
};
}

View File

@ -1,3 +0,0 @@
export * from './service';
export * from './regexp';
export * from './map-sdk';

View File

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

View File

@ -1,20 +0,0 @@
/** 手机号码正则 */
export const REGEXP_PHONE =
/^[1](([3][0-9])|([4][01456789])|([5][012356789])|([6][2567])|([7][0-8])|([8][0-9])|([9][012356789]))[0-9]{8}$/;
/** 邮箱正则 */
export const REGEXP_EMAIL = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/;
/** 密码正则(密码为6-18位数字/字符/符号的组合) */
export const REGEXP_PWD =
/^(?![0-9]+$)(?![a-z]+$)(?![A-Z]+$)(?!([^(0-9a-zA-Z)]|[()])+$)(?!^.*[\u4E00-\u9FA5].*$)([^(0-9a-zA-Z)]|[()]|[a-z]|[A-Z]|[0-9]){6,18}$/;
/** 6位数字验证码正则 */
export const REGEXP_CODE_SIX = /^\d{6}$/;
/** 4位数字验证码正则 */
export const REGEXP_CODE_FOUR = /^\d{4}$/;
/** url链接正则 */
export const REGEXP_URL =
/(((^https?:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)$/;

View File

@ -1,46 +0,0 @@
/** 请求超时时间 */
export const REQUEST_TIMEOUT = 60 * 1000;
/** 错误信息的显示时间 */
export const ERROR_MSG_DURATION = 3 * 1000;
/** 默认的请求错误code */
export const DEFAULT_REQUEST_ERROR_CODE = 'DEFAULT';
/** 默认的请求错误文本 */
export const DEFAULT_REQUEST_ERROR_MSG = '请求错误~';
/** 请求超时的错误code(为固定值ECONNABORTED) */
export const REQUEST_TIMEOUT_CODE = 'ECONNABORTED';
/** 请求超时的错误文本 */
export const REQUEST_TIMEOUT_MSG = '请求超时~';
/** 网络不可用的code */
export const NETWORK_ERROR_CODE = 'NETWORK_ERROR';
/** 网络不可用的错误文本 */
export const NETWORK_ERROR_MSG = '网络不可用~';
/** 请求不成功各种状态的错误 */
export const ERROR_STATUS = {
400: '400: 请求出现语法错误~',
401: '401: 用户未授权~',
403: '403: 服务器拒绝访问~',
404: '404: 请求的资源不存在~',
405: '405: 请求方法未允许~',
408: '408: 网络请求超时~',
500: '500: 服务器内部错误~',
501: '501: 服务器未实现请求功能~',
502: '502: 错误网关~',
503: '503: 服务不可用~',
504: '504: 网关超时~',
505: '505: http版本不支持该请求~',
[DEFAULT_REQUEST_ERROR_CODE]: DEFAULT_REQUEST_ERROR_MSG
};
/** 不弹出错误信息的code */
export const NO_ERROR_MSG_CODE: (string | number)[] = [];
/** token失效需要刷新token的code(这里的66666只是个例子需要将后端表示token过期的code填进来) */
export const REFRESH_TOKEN_CODE: (string | number)[] = [66666];

View File

@ -1,6 +0,0 @@
export function transformObjectToOption<T extends object>(obj: T) {
return Object.entries(obj).map(([value, label]) => ({
value,
label
})) as Common.OptionWithKey<keyof T>[];
}

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

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

View File

@ -1,33 +0,0 @@
import { $t } from '@/locales';
import { transformObjectToOption } from './_shared';
export const loginModuleLabels: Record<UnionKey.LoginModule, string> = {
'pwd-login': $t('page.login.pwdLogin.title'),
'code-login': $t('page.login.codeLogin.title'),
register: $t('page.login.register.title'),
'reset-pwd': $t('page.login.resetPwd.title'),
'bind-wechat': $t('page.login.bindWeChat.title')
};
export const userRoleLabels: Record<Auth.RoleType, string> = {
super: $t('page.login.pwdLogin.superAdmin'),
admin: $t('page.login.pwdLogin.admin'),
user: $t('page.login.pwdLogin.user')
};
export const userRoleOptions = transformObjectToOption(userRoleLabels);
/** 用户性别 */
export const genderLabels: Record<UserManagement.GenderKey, string> = {
0: '女',
1: '男'
};
export const genderOptions = transformObjectToOption(genderLabels);
/** 用户状态 */
export const userStatusLabels: Record<UserManagement.UserStatusKey, string> = {
1: '启用',
2: '禁用',
3: '冻结',
4: '软删除'
};
export const userStatusOptions = transformObjectToOption(userStatusLabels);

View File

@ -1,18 +0,0 @@
export const dataTypeLabels: { [K in TypeUtil.DataTypeStringKey]: TypeUtil.DataTypeString<K> } = {
string: '[object String]',
number: '[object Number]',
boolean: '[object Boolean]',
null: '[object Null]',
undefined: '[object Undefined]',
symbol: '[object Symbol]',
bigInt: '[object BigInt]',
object: '[object Object]',
function: '[object Function]',
array: '[object Array]',
date: '[object Date]',
regExp: '[object RegExp]',
promise: '[object Promise]',
set: '[object Set]',
map: '[object Map]',
file: '[object File]'
};

View File

@ -1,3 +0,0 @@
export * from './common';
export * from './system';
export * from './business';

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

@ -0,0 +1,34 @@
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
* @description 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]*))?)$/;

View File

@ -1,38 +0,0 @@
import { transformObjectToOption } from './_shared';
export const themeLayoutModeLabels: Record<UnionKey.ThemeLayoutMode, string> = {
vertical: '左侧菜单模式',
horizontal: '顶部菜单模式',
'vertical-mix': '左侧菜单混合模式',
'horizontal-mix': '顶部菜单混合模式'
};
export const themeLayoutModeOptions = transformObjectToOption(themeLayoutModeLabels);
export const themeScrollModeLabels: Record<UnionKey.ThemeScrollMode, string> = {
wrapper: '外层滚动',
content: '主体滚动'
};
export const themeScrollModeOptions = transformObjectToOption(themeScrollModeLabels);
export const themeTabModeLabels: Record<UnionKey.ThemeTabMode, string> = {
chrome: '谷歌风格',
button: '按钮风格'
};
export const themeTabModeOptions = transformObjectToOption(themeTabModeLabels);
export const themeHorizontalMenuPositionLabels: Record<UnionKey.ThemeHorizontalMenuPosition, string> = {
'flex-start': '居左',
center: '居中',
'flex-end': '居右'
};
export const themeHorizontalMenuPositionOptions = transformObjectToOption(themeHorizontalMenuPositionLabels);
export const themeAnimateModeLabels: Record<UnionKey.ThemeAnimateMode, string> = {
'zoom-fade': '渐变',
'zoom-out': '闪现',
'fade-slide': '滑动',
fade: '消退',
'fade-bottom': '底部消退',
'fade-scale': '缩放消退'
};
export const themeAnimateModeOptions = transformObjectToOption(themeAnimateModeLabels);

View File

@ -1,43 +0,0 @@
import { ref } from 'vue';
import type { Ref } from 'vue';
import { useContext } from '@/hooks';
interface DemoContext {
counts: Ref<number>;
setCounts: (count: number) => void;
}
const { useProvide: useDemoProvide, useInject: useDemoInject } = useContext<DemoContext>();
export function useDemoContext() {
const counts = ref(0);
function setCounts(count: number) {
counts.value = count;
}
const demoContext: DemoContext = {
counts,
setCounts
};
function useProvide() {
return useDemoProvide(demoContext);
}
return {
useProvide,
useInject: useDemoInject
};
}
// 示例用法: A.vue为父组件 B.vue为子孙组件 C.vue为子孙组件
// A.vue
// import { useDemoContext } from '@/context';
// const { useProvide } = useDemoContext();
// const { counts, setCounts } = useProvide();
// B.vue 和 C.vue : 共享状态 counts
// import { useDemoContext } from '@/context';
// const { useInject } = useDemoContext();
// const { counts, setCounts } = useInject();

View File

@ -1 +0,0 @@
export * from './demo';

View File

@ -1,11 +0,0 @@
import type { App } from 'vue';
import setupNetworkDirective from './network';
import setupLoginDirective from './login';
import setupPermissionDirective from './permission';
/** setup custom vue directives. - [安装自定义的vue指令] */
export function setupDirectives(app: App) {
setupNetworkDirective(app);
setupLoginDirective(app);
setupPermissionDirective(app);
}

View File

@ -1,27 +0,0 @@
import type { App, Directive } from 'vue';
import { useAuthStore } from '@/store';
import { useRouterPush } from '@/composables';
export default function setupLoginDirective(app: App) {
const auth = useAuthStore();
const { toLogin } = useRouterPush(false);
function listenerHandler(event: MouseEvent) {
if (!auth.isLogin) {
event.stopPropagation();
toLogin();
}
}
const loginDirective: Directive<HTMLElement, boolean | undefined> = {
mounted(el: HTMLElement, binding) {
if (binding.value === false) return;
el.addEventListener('click', listenerHandler, { capture: true });
},
unmounted(el: HTMLElement, binding) {
if (binding.value === false) return;
el.removeEventListener('click', listenerHandler);
}
};
app.directive('login', loginDirective);
}

View File

@ -1,25 +0,0 @@
import type { App, Directive } from 'vue';
import { NETWORK_ERROR_MSG } from '@/config';
export default function setupNetworkDirective(app: App) {
function listenerHandler(event: MouseEvent) {
const hasNetwork = window.navigator.onLine;
if (!hasNetwork) {
window.$message?.error(NETWORK_ERROR_MSG);
event.stopPropagation();
}
}
const networkDirective: Directive<HTMLElement, boolean | undefined> = {
mounted(el: HTMLElement, binding) {
if (binding.value === false) return;
el.addEventListener('click', listenerHandler, { capture: true });
},
unmounted(el: HTMLElement, binding) {
if (binding.value === false) return;
el.removeEventListener('click', listenerHandler);
}
};
app.directive('network', networkDirective);
}

View File

@ -1,26 +0,0 @@
import type { App, Directive } from 'vue';
import { usePermission } from '@/composables';
export default function setupPermissionDirective(app: App) {
const { hasPermission } = usePermission();
function updateElVisible(el: HTMLElement, permission: Auth.RoleType | Auth.RoleType[]) {
if (!permission) {
throw new Error(`need roles: like v-permission="'admin'", v-permission="['admin', 'test]"`);
}
if (!hasPermission(permission)) {
el.parentElement?.removeChild(el);
}
}
const permissionDirective: Directive<HTMLElement, Auth.RoleType | Auth.RoleType[]> = {
mounted(el, binding) {
updateElVisible(el, binding.value);
},
beforeUpdate(el, binding) {
updateElVisible(el, binding.value);
}
};
app.directive('permission', permissionDirective);
}

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

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

View File

@ -1,5 +0,0 @@
import useCountDown from './use-count-down';
import useSmsCode from './use-sms-code';
import useImageVerify from './use-image-verify';
export { useCountDown, useSmsCode, useImageVerify };

View File

@ -1,54 +0,0 @@
import { computed, onScopeDispose, ref } from 'vue';
import { useBoolean } from '../common';
/**
* 倒计时
* @param second - 倒计时的时间(s)
*/
export default function useCountDown(second: number) {
if (second <= 0 && second % 1 !== 0) {
throw new Error('倒计时的时间应该为一个正整数!');
}
const { bool: isComplete, setTrue, setFalse } = useBoolean(false);
const counts = ref(0);
const isCounting = computed(() => Boolean(counts.value));
let intervalId: any;
/**
* 开始计时
* @param updateSecond - 更改初时传入的倒计时时间
*/
function start(updateSecond: number = second) {
if (!counts.value) {
setFalse();
counts.value = updateSecond;
intervalId = setInterval(() => {
counts.value -= 1;
if (counts.value <= 0) {
clearInterval(intervalId);
setTrue();
}
}, 1000);
}
}
/**
* 停止计时
*/
function stop() {
intervalId = clearInterval(intervalId);
counts.value = 0;
}
onScopeDispose(stop);
return {
counts,
isCounting,
start,
stop,
isComplete
};
}

View File

@ -1,180 +0,0 @@
import { ref, reactive } from 'vue';
import type { Ref } from 'vue';
import type { PaginationProps, DataTableBaseColumn, DataTableSelectionColumn, DataTableExpandColumn } from 'naive-ui';
import type { TableColumnGroup } from 'naive-ui/es/data-table/src/interface';
import { useLoadingEmpty } from '../common';
/**
* 接口请求函数
*/
type ApiFn<T = any, R = any> = (args: T) => Promise<Service.RequestResult<R>>;
/**
* 接口请求函数的参数
*/
type GetApiFnParameters<T extends ApiFn, R = any> = T extends (args: infer P) => Promise<Service.RequestResult<R>>
? P
: never;
/**
* 接口请求函数的返回值
*/
type GetApiFnReturnType<T extends ApiFn, P = any> = T extends (args: P) => Promise<Service.RequestResult<infer R>>
? R
: never;
/**
* 表格接口请求后转换后的数据
*/
type Transformer<TableData, Response> = (response: Response) => {
data: TableData[];
pageNum: number;
pageSize: number;
total: number;
};
/**
* 列表接口参数更新
*/
type ApiParamsUpdater<P, R> = (params: P) => R;
/**
* 分页参数
*/
type PagePropsOfPagination = Pick<PaginationProps, 'page' | 'pageSize'>;
/**
* 自定义的列 key
*/
type CustomColumnKey<K = never> = K | 'action';
/**
* 表格的列
*/
type HookTableColumn<T = Record<string, unknown>> =
| (Omit<TableColumnGroup<T>, 'key'> & { key: CustomColumnKey<keyof T> })
| (Omit<DataTableBaseColumn<T>, 'key'> & { key: CustomColumnKey<keyof T> })
| DataTableSelectionColumn<T>
| DataTableExpandColumn<T>;
/**
* 表格配置
*/
type HookTableConfig<TableData, Fn extends ApiFn> = {
/**
* 列表接口参数
*/
apiParams: GetApiFnParameters<Fn>;
/**
* 列表接口返回数据转换
*/
transformer: Transformer<TableData, GetApiFnReturnType<Fn>>;
/**
* 列表列
*/
columns: () => HookTableColumn<TableData>[];
/**
* 列表接口参数更新
* @description 用于更新分页参数, 如果列表接口的参数不包含同名分页参数属性 `page` 和 `pageSize`, 需要通过此函数更新
* @default p => p
*/
apiParamsUpdater?: ApiParamsUpdater<GetApiFnParameters<Fn> & Partial<PagePropsOfPagination>, GetApiFnParameters<Fn>>;
/**
* 列表分页参数
*/
pagination?: PaginationProps;
/**
* 是否立即请求
* @default true
*/
immediate?: boolean;
};
/**
* 通用表格 hook
* @param apiFn 接口请求函数
* @param config 表格配置
*/
export default function useHookTable<TableData, Fn extends ApiFn>(apiFn: Fn, config: HookTableConfig<TableData, Fn>) {
const { loading, startLoading, endLoading, empty, setEmpty } = useLoadingEmpty();
const { apiParams, transformer, apiParamsUpdater = p => p, immediate = true } = config;
const data: Ref<TableData[]> = ref([]);
function updateData(update: TableData[]) {
data.value = update;
}
const columns = ref(config.columns()) as Ref<HookTableColumn<TableData>[]>;
const requestParams = ref(apiParams) as Ref<HookTableConfig<TableData, Fn>['apiParams']>;
function updateRequestParamsByPagination(p: PagePropsOfPagination) {
requestParams.value = apiParamsUpdater({ ...requestParams.value, ...p });
}
const pagination = reactive({
page: 1,
pageSize: 10,
showSizePicker: true,
pageSizes: [10, 15, 20, 25, 30],
onChange: (page: number) => {
pagination.page = page;
updateRequestParamsByPagination({ page });
getData();
},
onUpdatePageSize: (pageSize: number) => {
pagination.pageSize = pageSize;
pagination.page = 1;
updateRequestParamsByPagination({ pageSize });
getData();
},
...config.pagination
}) as PaginationProps;
function updatePagination(update: Partial<PaginationProps>) {
Object.assign(pagination, update);
updateRequestParamsByPagination({ page: pagination.page, pageSize: pagination.pageSize });
}
async function getData() {
startLoading();
const { data: apiData, error } = await apiFn(requestParams.value);
if (!error && data) {
const { data: tableData, pageNum, pageSize, total } = transformer(apiData);
updateData(tableData);
setEmpty(tableData.length === 0);
updatePagination({ page: pageNum, pageSize, itemCount: total });
}
endLoading();
}
function reloadColumns() {
columns.value = config.columns();
}
if (immediate) {
getData();
}
return {
data,
columns,
loading,
empty,
pagination,
getData,
updatePagination,
reloadColumns
};
}

View File

@ -1,87 +0,0 @@
import { onMounted, ref } from 'vue';
/**
* 绘制图形验证码
* @param width - 图形宽度
* @param height - 图形高度
*/
export default function useImageVerify(width = 152, height = 40) {
const domRef = ref<HTMLCanvasElement>();
const imgCode = ref('');
function setImgCode(code: string) {
imgCode.value = code;
}
function getImgCode() {
if (!domRef.value) return;
imgCode.value = draw(domRef.value, width, height);
}
onMounted(() => {
getImgCode();
});
return {
domRef,
imgCode,
setImgCode,
getImgCode
};
}
function randomNum(min: number, max: number) {
const num = Math.floor(Math.random() * (max - min) + min);
return num;
}
function randomColor(min: number, max: number) {
const r = randomNum(min, max);
const g = randomNum(min, max);
const b = randomNum(min, max);
return `rgb(${r},${g},${b})`;
}
function draw(dom: HTMLCanvasElement, width: number, height: number) {
let imgCode = '';
const NUMBER_STRING = '0123456789';
const ctx = dom.getContext('2d');
if (!ctx) return imgCode;
ctx.fillStyle = randomColor(180, 230);
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < 4; i += 1) {
const text = NUMBER_STRING[randomNum(0, NUMBER_STRING.length)];
imgCode += text;
const fontSize = randomNum(18, 41);
const deg = randomNum(-30, 30);
ctx.font = `${fontSize}px Simhei`;
ctx.textBaseline = 'top';
ctx.fillStyle = randomColor(80, 150);
ctx.save();
ctx.translate(30 * i + 23, 15);
ctx.rotate((deg * Math.PI) / 180);
ctx.fillText(text, -15 + 5, -15);
ctx.restore();
}
for (let i = 0; i < 5; i += 1) {
ctx.beginPath();
ctx.moveTo(randomNum(0, width), randomNum(0, height));
ctx.lineTo(randomNum(0, width), randomNum(0, height));
ctx.strokeStyle = randomColor(180, 230);
ctx.closePath();
ctx.stroke();
}
for (let i = 0; i < 41; i += 1) {
ctx.beginPath();
ctx.arc(randomNum(0, width), randomNum(0, height), 1, 0, 2 * Math.PI);
ctx.closePath();
ctx.fillStyle = randomColor(150, 200);
ctx.fill();
}
return imgCode;
}

View File

@ -1,61 +0,0 @@
import { computed } from 'vue';
import { REGEXP_PHONE } from '@/config';
import { fetchSmsCode } from '@/service';
import { useLoading } from '../common';
import useCountDown from './use-count-down';
export default function useSmsCode() {
const { loading, startLoading, endLoading } = useLoading();
const { counts, start, isCounting } = useCountDown(60);
const initLabel = '获取验证码';
const countingLabel = (second: number) => `${second}秒后重新获取`;
const label = computed(() => {
let text = initLabel;
if (loading.value) {
text = '';
}
if (isCounting.value) {
text = countingLabel(counts.value);
}
return text;
});
/** 判断手机号码格式是否正确 */
function isPhoneValid(phone: string) {
let valid = true;
if (phone.trim() === '') {
window.$message?.error('手机号码不能为空!');
valid = false;
} else if (!REGEXP_PHONE.test(phone)) {
window.$message?.error('手机号码格式错误!');
valid = false;
}
return valid;
}
/**
* 获取短信验证码
* @param phone - 手机号
*/
async function getSmsCode(phone: string) {
const valid = isPhoneValid(phone);
if (!valid || loading.value) return;
startLoading();
const { data } = await fetchSmsCode(phone);
if (data) {
window.$message?.success('验证码发送成功!');
start();
}
endLoading();
}
return {
label,
start,
isCounting,
getSmsCode,
loading
};
}

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

@ -0,0 +1,74 @@
import { ref } from 'vue';
import type { FormInst } from 'naive-ui';
import { REG_USER_NAME, REG_PHONE, REG_PWD, REG_CODE_SIX, REG_EMAIL } from '@/constants/reg';
import { $t } from '@/locales';
export function useFormRules() {
const constantRules = {
userName: [
createRequiredRule($t('form.userName.required')),
{
pattern: REG_USER_NAME,
message: $t('form.userName.invalid'),
trigger: 'change'
}
],
phone: [
createRequiredRule($t('form.phone.required')),
{
pattern: REG_PHONE,
message: $t('form.phone.invalid'),
trigger: 'change'
}
],
pwd: [
createRequiredRule($t('form.pwd.required')),
{
pattern: REG_PWD,
message: $t('form.pwd.invalid'),
trigger: 'change'
}
],
code: [
createRequiredRule($t('form.code.required')),
{
pattern: REG_CODE_SIX,
message: $t('form.code.invalid'),
trigger: 'change'
}
],
email: [
createRequiredRule($t('form.email.required')),
{
pattern: REG_EMAIL,
message: $t('form.email.invalid'),
trigger: 'change'
}
]
} satisfies Record<string, App.Global.FormRule[]>;
function createRequiredRule(message: string) {
return {
required: true,
message
};
}
return {
constantRules,
createRequiredRule
};
}
export function useNaiveForm() {
const formRef = ref<FormInst | null>(null);
async function validate() {
await formRef.value?.validate();
}
return {
formRef,
validate
};
}

View File

@ -1,7 +0,0 @@
import useContext from './use-context';
import useBoolean from './use-boolean';
import useLoading from './use-loading';
import useLoadingEmpty from './use-loading-empty';
import useReload from './use-reload';
export { useContext, useBoolean, useLoading, useLoadingEmpty, useReload };

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

@ -0,0 +1,101 @@
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
* @description jump to the specified route, it can replace function router.push
* @param inSetup whether is in vue script setup
*/
export function useRouterPush(inSetup = true) {
const router = inSetup ? useRouter() : globalRouter;
const route = globalRouter.currentRoute;
const routerPush = router.push;
const routerBack = router.back;
interface RouterPushOptions {
query?: Record<string, string>;
params?: Record<string, string>;
}
async function routerPushByKey(key: RouteKey, options?: RouterPushOptions) {
const { query, params } = options || {};
const routeLocation: RouteLocationRaw = {
name: key
};
if (query) {
routeLocation.query = query;
}
if (params) {
routeLocation.params = params;
}
return routerPush(routeLocation);
}
async function toHome() {
return routerPushByKey('root');
}
/**
* navigate to login page
* @param loginModule the login module
* @param redirectUrl the redirect url, if not specified, it will be the current route fullPath
*/
async function toLogin(loginModule?: UnionKey.LoginModule, redirectUrl?: string) {
const module = loginModule || 'pwd-login';
const options: RouterPushOptions = {
params: {
module
}
};
const redirect = redirectUrl || route.value.fullPath;
options.query = {
redirect
};
return routerPushByKey('login', options);
}
/**
* toggle login module
* @param module
*/
async function toggleLoginModule(module: UnionKey.LoginModule) {
const query = route.value.query as Record<string, string>;
return routerPushByKey('login', { query, params: { module } });
}
/**
* redirect from login
*/
async function redirectFromLogin() {
const redirect = route.value.query?.redirect as string;
if (redirect) {
routerPush(redirect);
} else {
toHome();
}
}
return {
route,
routerPush,
routerBack,
routerPushByKey,
toLogin,
toggleLoginModule,
redirectFromLogin
};
}

View File

@ -1,30 +0,0 @@
import { ref } from 'vue';
/**
* boolean组合式函数
* @param initValue 初始值
*/
export default function useBoolean(initValue = false) {
const bool = ref(initValue);
function setBool(value: boolean) {
bool.value = value;
}
function setTrue() {
setBool(true);
}
function setFalse() {
setBool(false);
}
function toggle() {
setBool(!bool.value);
}
return {
bool,
setBool,
setTrue,
setFalse,
toggle
};
}

View File

@ -1,21 +0,0 @@
import { inject, provide } from 'vue';
import type { InjectionKey } from 'vue';
/** 创建共享上下文状态 */
export default function useContext<T>(contextName = 'context') {
const injectKey: InjectionKey<T> = Symbol(contextName);
function useProvide(context: T) {
provide(injectKey, context);
return context;
}
function useInject() {
return inject(injectKey) as T;
}
return {
useProvide,
useInject
};
}

View File

@ -1,14 +0,0 @@
import useBoolean from './use-boolean';
export default function useLoadingEmpty(initLoading = false, initEmpty = false) {
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initLoading);
const { bool: empty, setBool: setEmpty } = useBoolean(initEmpty);
return {
loading,
startLoading,
endLoading,
empty,
setEmpty
};
}

View File

@ -1,11 +0,0 @@
import useBoolean from './use-boolean';
export default function useLoading(initValue = false) {
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(initValue);
return {
loading,
startLoading,
endLoading
};
}

View File

@ -1,28 +0,0 @@
import { nextTick } from 'vue';
import useBoolean from './use-boolean';
/** 重载 */
export default function useReload() {
// 重载的标志
const { bool: reloadFlag, setTrue, setFalse } = useBoolean(true);
/**
* 触发重载
* @param duration - 延迟时间(ms)
*/
async function handleReload(duration = 0) {
setFalse();
await nextTick();
if (duration > 0) {
setTimeout(() => {
setTrue();
}, duration);
}
}
return {
reloadFlag,
handleReload
};
}

View File

@ -1,2 +0,0 @@
export * from './common';
export * from './business';

View File

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

View File

@ -1,62 +0,0 @@
<template>
<admin-layout
:mode="mode"
:is-mobile="isMobile"
:scroll-mode="theme.scrollMode"
:scroll-el-id="app.scrollElId"
:full-content="app.contentFull"
:fixed-top="theme.fixedHeaderAndTab"
:header-height="theme.header.height"
:tab-visible="theme.tab.visible"
:tab-height="theme.tab.height"
:content-class="app.disableMainXScroll ? 'overflow-x-hidden' : ''"
:sider-visible="siderVisible"
:sider-collapse="app.siderCollapse"
:sider-width="siderWidth"
:sider-collapsed-width="siderCollapsedWidth"
:footer-visible="theme.footer.visible"
:fixed-footer="theme.footer.fixed"
:right-footer="theme.footer.right"
@click-mobile-sider-mask="app.setSiderCollapse(true)"
>
<template #header>
<global-header v-bind="headerProps" />
</template>
<template #tab>
<global-tab />
</template>
<template #sider>
<global-sider />
</template>
<global-content />
<template #footer>
<global-footer />
</template>
</admin-layout>
<n-back-top :key="theme.scrollMode" :listen-to="`#${app.scrollElId}`" class="z-100" />
<setting-drawer />
</template>
<script setup lang="ts">
import { AdminLayout } from '@soybeanjs/vue-materials';
import { useAppStore, useThemeStore } from '@/store';
import { useBasicLayout } from '@/composables';
import { GlobalContent, GlobalFooter, GlobalHeader, GlobalSider, GlobalTab, SettingDrawer } from '../common';
defineOptions({ name: 'BasicLayout' });
const app = useAppStore();
const theme = useThemeStore();
const { mode, isMobile, headerProps, siderVisible, siderWidth, siderCollapsedWidth } = useBasicLayout();
</script>
<style lang="scss">
#__SCROLL_EL_ID__ {
@include scrollbar(8px, #e1e1e1);
}
.dark #__SCROLL_EL_ID__ {
@include scrollbar(8px, #555);
}
</style>

View File

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

View File

@ -1,42 +0,0 @@
<template>
<router-view v-slot="{ Component, route }">
<transition
:name="theme.pageAnimateMode"
mode="out-in"
:appear="true"
@before-leave="app.setDisableMainXScroll(true)"
@after-enter="app.setDisableMainXScroll(false)"
>
<keep-alive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="app.reloadFlag"
:key="route.fullPath"
:class="{ 'p-16px': showPadding }"
class="flex-grow bg-#f6f9f8 dark:bg-#101014 transition duration-300 ease-in-out"
/>
</keep-alive>
</transition>
</router-view>
</template>
<script setup lang="ts">
import { useAppStore, useRouteStore, useThemeStore } from '@/store';
defineOptions({ name: 'GlobalContent' });
interface Props {
/** 显示padding */
showPadding?: boolean;
}
withDefaults(defineProps<Props>(), {
showPadding: true
});
const app = useAppStore();
const theme = useThemeStore();
const routeStore = useRouteStore();
</script>
<style scoped></style>

View File

@ -1,15 +0,0 @@
<template>
<dark-mode-container class="flex-center h-full" :inverted="theme.footer.inverted">
<span>Copyright ©2021 Soybean Admin</span>
</dark-mode-container>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/store';
defineOptions({ name: 'GlobalFooter' });
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,18 +0,0 @@
<template>
<hover-container class="w-40px h-full" tooltip-content="全屏" :inverted="theme.header.inverted" @click="toggle">
<icon-gridicons-fullscreen-exit v-if="isFullscreen" class="text-18px" />
<icon-gridicons-fullscreen v-else class="text-18px" />
</hover-container>
</template>
<script lang="ts" setup>
import { useFullscreen } from '@vueuse/core';
import { useThemeStore } from '@/store';
defineOptions({ name: 'FullScreen' });
const { isFullscreen, toggle } = useFullscreen();
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,23 +0,0 @@
<template>
<hover-container
tooltip-content="github"
class="w-40px h-full"
:inverted="theme.header.inverted"
@click="handleClickLink"
>
<icon-mdi-github class="text-20px" />
</hover-container>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
defineOptions({ name: 'GithubSite' });
const theme = useThemeStore();
function handleClickLink() {
window.open('https://github.com/honghuangdc/soybean-admin', '_blank');
}
</script>
<style scoped></style>

View File

@ -1,62 +0,0 @@
<template>
<n-breadcrumb class="px-12px">
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.key">
<n-breadcrumb-item>
<n-dropdown v-if="breadcrumb.hasChildren" :options="breadcrumb.options" @select="dropdownSelect">
<span>
<component
:is="breadcrumb.icon"
v-if="theme.header.crumb.showIcon"
class="inline-block align-text-bottom mr-4px text-16px"
/>
<span>{{ breadcrumb.label }}</span>
</span>
</n-dropdown>
<template v-else>
<component
:is="breadcrumb.icon"
v-if="theme.header.crumb.showIcon"
class="inline-block align-text-bottom mr-4px text-16px"
:class="{ 'text-#BBBBBB': theme.header.inverted }"
/>
<span :class="{ 'text-#BBBBBB': theme.header.inverted }">
{{ breadcrumb.label }}
</span>
</template>
</n-breadcrumb-item>
</template>
</n-breadcrumb>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { routePath } from '@/router';
import { useRouteStore, useThemeStore } from '@/store';
import { useRouterPush } from '@/composables';
import { getBreadcrumbByRouteKey } from '@/utils';
import { $t } from '@/locales';
defineOptions({ name: 'GlobalBreadcrumb' });
const route = useRoute();
const theme = useThemeStore();
const routeStore = useRouteStore();
const { routerPush } = useRouterPush();
const breadcrumbs = computed(() =>
getBreadcrumbByRouteKey(route.name as string, routeStore.menus as App.GlobalMenuOption[], routePath('root')).map(
item => ({
...item,
label: item.i18nTitle ? $t(item.i18nTitle) : item.label,
options: item.options?.map(oItem => ({ ...oItem, label: oItem.i18nTitle ? $t(oItem.i18nTitle) : oItem.label }))
})
)
);
function dropdownSelect(key: string) {
routerPush({ name: key });
}
</script>
<style scoped></style>

View File

@ -1,45 +0,0 @@
<template>
<div class="flex-1-hidden h-full px-10px">
<n-scrollbar :x-scrollable="true" class="flex-1-hidden h-full" content-class="h-full">
<div class="flex-y-center h-full" :style="{ justifyContent: theme.menu.horizontalPosition }">
<n-menu
:value="activeKey"
mode="horizontal"
:options="menus"
:inverted="theme.header.inverted"
@update:value="handleUpdateMenu"
/>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import type { MenuOption } from 'naive-ui';
import { useRouteStore, useThemeStore } from '@/store';
import { useRouterPush } from '@/composables';
import { translateMenuLabel } from '@/utils';
defineOptions({ name: 'HeaderMenu' });
const route = useRoute();
const routeStore = useRouteStore();
const theme = useThemeStore();
const { routerPush } = useRouterPush();
const menus = computed(() => translateMenuLabel(routeStore.menus as App.GlobalMenuOption[]));
const activeKey = computed(() => (route.meta?.activeMenu ? route.meta.activeMenu : route.name) as string);
function handleUpdateMenu(_key: string, item: MenuOption) {
const menuItem = item as App.GlobalMenuOption;
routerPush(menuItem.routePath);
}
</script>
<style scoped>
:deep(.n-menu-item-content-header) {
overflow: inherit !important;
}
</style>

View File

@ -1,23 +0,0 @@
import MenuCollapse from './menu-collapse.vue';
import GlobalBreadcrumb from './global-breadcrumb.vue';
import HeaderMenu from './header-menu.vue';
import GithubSite from './github-site.vue';
import FullScreen from './full-screen.vue';
import ThemeMode from './theme-mode.vue';
import UserAvatar from './user-avatar.vue';
import SystemMessage from './system-message.vue';
import SettingButton from './setting-button.vue';
import ToggleLang from './toggle-lang.vue';
export {
MenuCollapse,
GlobalBreadcrumb,
HeaderMenu,
GithubSite,
FullScreen,
ThemeMode,
UserAvatar,
SystemMessage,
SettingButton,
ToggleLang
};

View File

@ -1,17 +0,0 @@
<template>
<hover-container class="w-40px h-full" :inverted="theme.header.inverted" @click="app.toggleSiderCollapse">
<icon-line-md-menu-unfold-left v-if="app.siderCollapse" class="text-16px" />
<icon-line-md-menu-fold-left v-else class="text-16px" />
</hover-container>
</template>
<script lang="ts" setup>
import { useAppStore, useThemeStore } from '@/store';
defineOptions({ name: 'MenuCollapse' });
const app = useAppStore();
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,57 +0,0 @@
<template>
<n-scrollbar class="max-h-360px">
<n-list>
<n-list-item
v-for="(item, index) in list"
:key="item.id"
class="hover:bg-#f6f6f6 dark:hover:bg-dark cursor-pointer"
@click="handleRead(index)"
>
<n-thing class="px-15px" :class="{ 'opacity-30': item.isRead }">
<template #avatar>
<n-avatar v-if="item.avatar" :src="item.avatar" />
<svg-icon v-else class="text-34px text-primary" :icon="item.icon" :local-icon="item.svgIcon" />
</template>
<template #header>
<n-ellipsis :line-clamp="1">
{{ item.title }}
<template #tooltip>
{{ item.title }}
</template>
</n-ellipsis>
</template>
<template v-if="item.tagTitle" #header-extra>
<n-tag v-bind="item.tagProps" size="small">{{ item.tagTitle }}</n-tag>
</template>
<template #description>
<n-ellipsis v-if="item.description" :line-clamp="2">
{{ item.description }}
</n-ellipsis>
<p>{{ item.date }}</p>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-scrollbar>
</template>
<script lang="ts" setup>
defineOptions({ name: 'MessageList' });
interface Props {
list?: App.MessageList[];
}
withDefaults(defineProps<Props>(), {
list: () => []
});
interface Emits {
(e: 'read', val: number): void;
}
const emit = defineEmits<Emits>();
function handleRead(index: number) {
emit('read', index);
}
</script>

View File

@ -1,21 +0,0 @@
<template>
<hover-container
class="w-40px h-full"
tooltip-content="主题配置"
:inverted="theme.header.inverted"
@click="app.toggleSettingDrawerVisible"
>
<icon-ant-design-setting-outlined class="text-20px" />
</hover-container>
</template>
<script setup lang="ts">
import { useAppStore, useThemeStore } from '@/store';
defineOptions({ name: 'SettingButton' });
const app = useAppStore();
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,217 +0,0 @@
<template>
<n-popover class="!p-0" trigger="click" placement="bottom">
<template #trigger>
<hover-container tooltip-content="消息通知" :inverted="theme.header.inverted" class="relative w-40px h-full">
<icon-clarity:notification-line class="text-18px" />
<n-badge
:value="count"
:max="99"
:class="[count < 10 ? '-right-2px' : '-right-10px']"
class="absolute top-10px"
/>
</hover-container>
</template>
<n-tabs
v-model:value="currentTab"
:class="[isMobile ? 'w-276px' : 'w-360px']"
type="line"
justify-content="space-evenly"
>
<n-tab-pane v-for="(item, index) in tabData" :key="item.key" :name="index">
<template #tab>
<div class="flex-x-center items-center" :class="[isMobile ? 'w-92px' : 'w-120px']">
<span class="mr-5px">{{ item.name }}</span>
<n-badge
v-bind="item.badgeProps"
:value="item.list.filter(message => !message.isRead).length"
:max="99"
show-zero
/>
</div>
</template>
<loading-empty-wrapper
class="h-360px"
:loading="loading"
:empty="item.list.length === 0"
placeholder-class="bg-$n-color transition-background-color duration-300 ease-in-out"
>
<message-list :list="item.list" @read="handleRead" />
</loading-empty-wrapper>
</n-tab-pane>
</n-tabs>
<div v-if="showAction" class="flex border-t border-$n-divider-color cursor-pointer">
<div class="flex-1 text-center py-10px" @click="handleClear">清空</div>
<div class="flex-1 text-center py-10px border-l border-$n-divider-color" @click="handleAllRead">全部已读</div>
<div class="flex-1 text-center py-10px border-l border-$n-divider-color" @click="handleLoadMore">查看更多</div>
</div>
</n-popover>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useThemeStore } from '@/store';
import { useBasicLayout } from '@/composables';
import { useBoolean } from '@/hooks';
import MessageList from './message-list.vue';
defineOptions({ name: 'SystemMessage' });
const theme = useThemeStore();
const { isMobile } = useBasicLayout();
const { bool: loading, setBool: setLoading } = useBoolean();
const currentTab = ref(0);
const tabData = ref<App.MessageTab[]>([
{
key: 1,
name: '通知',
badgeProps: { type: 'warning' },
list: [
{ id: 1, icon: 'ri:message-3-line', title: '你收到了5条新消息', date: '2022-06-17' },
{ id: 4, icon: 'ri:message-3-line', title: 'Soybean Admin 1.0.0 版本正在筹备中', date: '2022-06-17' },
{ id: 2, icon: 'ri:message-3-line', title: 'Soybean Admin 0.9.6 版本发布了', date: '2022-06-16' },
{ id: 3, icon: 'ri:message-3-line', title: 'Soybean Admin 0.9.5 版本发布了', date: '2022-06-07' },
{
id: 5,
icon: 'ri:message-3-line',
title: '测试超长标题测试超长标题测试超长标题测试超长标题测试超长标题测试超长标题测试超长标题测试超长标题',
date: '2022-06-17'
}
]
},
{
key: 2,
name: '消息',
badgeProps: { type: 'error' },
list: [
{
id: 1,
title: '项目动态',
svgIcon: 'avatar',
description: 'Soybean 刚才把工作台页面随便写了一些,凑合能看了!',
date: '2021-11-07 22:45:32'
},
{
id: 2,
title: '项目动态',
svgIcon: 'avatar',
description: 'Soybean 正在忙于为soybean-admin写项目说明文档',
date: '2021-11-03 20:33:31'
},
{
id: 3,
title: '项目动态',
svgIcon: 'avatar',
description: 'Soybean 准备为soybean-admin 1.0的发布做充分的准备工作!',
date: '2021-10-31 22:43:12'
},
{
id: 4,
title: '项目动态',
svgIcon: 'avatar',
description: '@yanbowe 向soybean-admin提交了一个bug多标签栏不会自适应。',
date: '2021-10-27 10:24:54'
},
{
id: 5,
title: '项目动态',
svgIcon: 'avatar',
description: 'Soybean 在2021年5月28日创建了开源项目soybean-admin',
date: '2021-05-28 22:22:22'
}
]
},
{
key: 3,
name: '待办',
badgeProps: { type: 'info' },
list: [
{
id: 1,
icon: 'ri:calendar-todo-line',
title: '缓存主题配置',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 2,
icon: 'ri:calendar-todo-line',
title: '添加锁屏组件、全局Iframe组件',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 3,
icon: 'ri:calendar-todo-line',
title: '示例页面完善',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 4,
icon: 'ri:calendar-todo-line',
title: '表单、表格示例',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 5,
icon: 'ri:calendar-todo-line',
title: '性能优化(优化递归函数)',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
},
{
id: 6,
icon: 'ri:calendar-todo-line',
title: '精简版(新分支thin)',
description: '任务正在计划中',
date: '2022-06-17',
tagTitle: '未开始',
tagProps: { type: 'default' }
}
]
}
]);
const count = computed(() => {
return tabData.value.reduce((acc, cur) => {
return acc + cur.list.filter(item => !item.isRead).length;
}, 0);
});
const showAction = computed(() => tabData.value[currentTab.value].list.length > 0);
function handleRead(index: number) {
tabData.value[currentTab.value].list[index].isRead = true;
}
function handleAllRead() {
tabData.value[currentTab.value].list.forEach(item => Object.assign(item, { isRead: true }));
}
function handleClear() {
tabData.value[currentTab.value].list = [];
}
function handleLoadMore() {
const { list } = tabData.value[currentTab.value];
setLoading(true);
setTimeout(() => {
list.push(...tabData.value[currentTab.value].list);
setLoading(false);
}, 1000);
}
</script>
<style scoped></style>

View File

@ -1,20 +0,0 @@
<template>
<hover-container class="w-40px" :inverted="theme.header.inverted" tooltip-content="主题模式">
<dark-mode-switch
:dark="theme.darkMode"
:customize-transition="theme.isCustomizeDarkModeTransition"
class="wh-full"
@update:dark="theme.setDarkMode"
/>
</hover-container>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
defineOptions({ name: 'ThemeMode' });
const theme = useThemeStore();
</script>
<style scoped></style>

View File

@ -1,39 +0,0 @@
<template>
<hover-container class="w-40px h-full" :inverted="theme.header.inverted">
<n-dropdown :options="options" trigger="hover" :value="language" @select="handleSelect">
<icon-cil:language class="text-18px outline-transparent" />
</n-dropdown>
</hover-container>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useThemeStore } from '@/store';
import { localStg } from '@/utils';
const theme = useThemeStore();
const { locale } = useI18n();
const language = ref<I18nType.LangType>(localStg.get('lang') || 'zh-CN');
const options = [
{
label: '中文',
key: 'zh-CN'
},
{
label: 'English',
key: 'en'
},
{
label: 'ភាសាខ្មែរ',
key: 'km-KH'
}
];
const handleSelect = (key: string) => {
language.value = key as I18nType.LangType;
locale.value = key;
localStg.set('lang', key as I18nType.LangType);
};
</script>
<style scoped></style>

View File

@ -1,56 +0,0 @@
<template>
<n-dropdown :options="options" @select="handleDropdown">
<hover-container class="px-12px" :inverted="theme.header.inverted">
<icon-local-avatar class="text-32px" />
<span class="pl-8px text-16px font-medium">{{ auth.userInfo.userName }}</span>
</hover-container>
</n-dropdown>
</template>
<script lang="ts" setup>
import type { DropdownOption } from 'naive-ui';
import { useAuthStore, useThemeStore } from '@/store';
import { useIconRender } from '@/composables';
defineOptions({ name: 'UserAvatar' });
const auth = useAuthStore();
const theme = useThemeStore();
const { iconRender } = useIconRender();
const options: DropdownOption[] = [
{
label: '用户中心',
key: 'user-center',
icon: iconRender({ icon: 'carbon:user-avatar' })
},
{
type: 'divider',
key: 'divider'
},
{
label: '退出登录',
key: 'logout',
icon: iconRender({ icon: 'carbon:logout' })
}
];
type DropdownKey = 'user-center' | 'logout';
function handleDropdown(optionKey: string) {
const key = optionKey as DropdownKey;
if (key === 'logout') {
window.$dialog?.info({
title: '提示',
content: '您确定要退出登录吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
auth.resetAuthStore();
}
});
}
}
</script>
<style scoped></style>

View File

@ -1,63 +0,0 @@
<template>
<dark-mode-container class="global-header flex-y-center h-full" :inverted="theme.header.inverted">
<global-logo v-if="showLogo" :show-title="true" class="h-full" :style="{ width: theme.sider.width + 'px' }" />
<div v-if="!showHeaderMenu" class="flex-1-hidden flex-y-center h-full">
<menu-collapse v-if="showMenuCollapse || isMobile" />
<global-breadcrumb v-if="theme.header.crumb.visible && !isMobile" />
</div>
<header-menu v-else />
<div class="flex justify-end h-full">
<global-search />
<github-site />
<full-screen />
<theme-mode />
<toggle-lang />
<system-message />
<setting-button v-if="showButton" />
<user-avatar />
</div>
</dark-mode-container>
</template>
<script setup lang="ts">
import { useThemeStore } from '@/store';
import { useBasicLayout } from '@/composables';
import GlobalLogo from '../global-logo/index.vue';
import GlobalSearch from '../global-search/index.vue';
import {
FullScreen,
GithubSite,
GlobalBreadcrumb,
HeaderMenu,
MenuCollapse,
SettingButton,
SystemMessage,
ThemeMode,
UserAvatar,
ToggleLang
} from './components';
defineOptions({ name: 'GlobalHeader' });
interface Props {
/** 显示logo */
showLogo: App.GlobalHeaderProps['showLogo'];
/** 显示头部菜单 */
showHeaderMenu: App.GlobalHeaderProps['showHeaderMenu'];
/** 显示菜单折叠按钮 */
showMenuCollapse: App.GlobalHeaderProps['showMenuCollapse'];
}
defineProps<Props>();
const theme = useThemeStore();
const { isMobile } = useBasicLayout();
const showButton = import.meta.env.PROD && import.meta.env.VITE_VERCEL !== 'Y';
</script>
<style scoped>
.global-header {
box-shadow: 0 1px 2px rgb(0 21 41 / 8%);
}
</style>

View File

@ -1,26 +0,0 @@
<template>
<router-link :to="routeHomePath" class="flex-center w-full nowrap-hidden">
<system-logo class="text-32px text-primary" />
<h2 v-show="showTitle" class="pl-8px text-16px font-bold text-primary transition duration-300 ease-in-out">
{{ $t('system.title') }}
</h2>
</router-link>
</template>
<script setup lang="ts">
import { routePath } from '@/router';
import { $t } from '@/locales';
defineOptions({ name: 'GlobalLogo' });
interface Props {
/** 显示名字 */
showTitle: boolean;
}
defineProps<Props>();
const routeHomePath = routePath('root');
</script>
<style scoped></style>

View File

@ -1,3 +0,0 @@
import SearchModal from './search-modal.vue';
export { SearchModal };

View File

@ -1,30 +0,0 @@
<template>
<div class="px-24px h-44px flex-y-center">
<span class="mr-14px flex-y-center">
<icon-mdi-keyboard-return class="icon text-20px p-2px mr-6px" />
<span>确认</span>
</span>
<span class="mr-14px flex-y-center">
<icon-mdi-arrow-up-thin class="icon text-20px p-2px mr-5px" />
<icon-mdi-arrow-down-thin class="icon text-20px p-2px mr-6px" />
<span>切换</span>
</span>
<span class="flex-y-center">
<icon-mdi-keyboard-esc class="icon text-20px p-2px mr-6px" />
<span>关闭</span>
</span>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'SearchFooter' });
</script>
<style lang="scss" scoped>
.icon {
box-shadow:
inset 0 -2px #cdcde6,
inset 0 0 1px 1px #fff,
0 1px 2px 1px #1e235a66;
}
</style>

View File

@ -1,146 +0,0 @@
<template>
<n-modal
v-model:show="show"
:segmented="{ footer: 'soft' }"
:closable="false"
preset="card"
footer-style="padding: 0; margin: 0"
class="fixed left-0 right-0"
:class="[isMobile ? 'wh-full top-0px rounded-0' : 'w-630px top-50px']"
@after-leave="handleClose"
>
<n-input-group>
<n-input ref="inputRef" v-model:value="keyword" clearable placeholder="请输入关键词搜索" @input="handleSearch">
<template #prefix>
<icon-uil-search class="text-15px text-#c2c2c2" />
</template>
</n-input>
<n-button v-if="isMobile" type="primary" ghost @click="handleClose">取消</n-button>
</n-input-group>
<div class="mt-20px">
<n-empty v-if="resultOptions.length === 0" description="暂无搜索结果" />
<search-result v-else v-model:value="activePath" :options="resultOptions" @enter="handleEnter" />
</div>
<template #footer>
<search-footer v-if="!isMobile" />
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, shallowRef, watch } from 'vue';
import { useRouter } from 'vue-router';
import { onKeyStroke, useDebounceFn } from '@vueuse/core';
import { useRouteStore } from '@/store';
import { useBasicLayout } from '@/composables';
import { $t } from '@/locales';
import SearchResult from './search-result.vue';
import SearchFooter from './search-footer.vue';
defineOptions({ name: 'SearchModal' });
interface Props {
/** 弹窗显隐 */
value: boolean;
}
const props = defineProps<Props>();
interface Emits {
(e: 'update:value', val: boolean): void;
}
const emit = defineEmits<Emits>();
const { isMobile } = useBasicLayout();
const router = useRouter();
const routeStore = useRouteStore();
const keyword = ref('');
const activePath = ref('');
const resultOptions = shallowRef<AuthRoute.Route[]>([]);
const inputRef = ref<HTMLInputElement>();
const handleSearch = useDebounceFn(search, 300);
const show = computed({
get() {
return props.value;
},
set(val: boolean) {
emit('update:value', val);
}
});
watch(show, async val => {
if (val) {
/** 自动聚焦 */
await nextTick();
inputRef.value?.focus();
}
});
/** 查询 */
function search() {
resultOptions.value = routeStore.searchMenus.filter(menu => {
const trimKeyword = keyword.value.toLocaleLowerCase().trim();
const title = (menu.meta.i18nTitle ? $t(menu.meta.i18nTitle) : menu.meta.title).toLocaleLowerCase();
return trimKeyword && title.includes(trimKeyword);
});
activePath.value = resultOptions.value[0]?.path ?? '';
}
function handleClose() {
show.value = false;
/** 延时处理防止用户看到某些操作 */
setTimeout(() => {
resultOptions.value = [];
keyword.value = '';
}, 200);
}
/** key up */
function handleUp() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(item => item.path === activePath.value);
if (index === 0) {
activePath.value = resultOptions.value[length - 1].path;
} else {
activePath.value = resultOptions.value[index - 1].path;
}
}
/** key down */
function handleDown() {
const { length } = resultOptions.value;
if (length === 0) return;
const index = resultOptions.value.findIndex(item => item.path === activePath.value);
if (index + 1 === length) {
activePath.value = resultOptions.value[0].path;
} else {
activePath.value = resultOptions.value[index + 1].path;
}
}
/** key enter */
function handleEnter() {
const { length } = resultOptions.value;
if (length === 0 || activePath.value === '') return;
const routeItem = resultOptions.value.find(item => item.path === activePath.value);
if (routeItem?.meta?.href) {
window.open(activePath.value, '__blank');
} else {
router.push(activePath.value);
handleClose();
}
}
onKeyStroke('Escape', handleClose);
onKeyStroke('Enter', handleEnter);
onKeyStroke('ArrowUp', handleUp);
onKeyStroke('ArrowDown', handleDown);
</script>
<style lang="scss" scoped></style>

View File

@ -1,67 +0,0 @@
<template>
<n-scrollbar>
<div class="pb-12px">
<template v-for="item in options" :key="item.path">
<div
class="bg-#e5e7eb dark:bg-dark h-56px mt-8px px-14px rounded-4px cursor-pointer flex-y-center justify-between"
:style="{
background: item.path === active ? theme.themeColor : '',
color: item.path === active ? '#fff' : ''
}"
@click="handleTo"
@mouseenter="handleMouse(item)"
>
<svg-icon :icon="item.meta.icon" :local-icon="item.meta.localIcon" />
<span class="flex-1 ml-5px">
{{ (item.meta?.i18nTitle && $t(item.meta?.i18nTitle)) || item.meta?.title }}
</span>
<icon-ant-design-enter-outlined class="icon text-20px p-2px mr-3px" />
</div>
</template>
</div>
</n-scrollbar>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useThemeStore } from '@/store';
import { $t } from '@/locales';
defineOptions({ name: 'SearchResult' });
interface Props {
value: string;
options: AuthRoute.Route[];
}
const props = defineProps<Props>();
interface Emits {
(e: 'update:value', val: string): void;
(e: 'enter'): void;
}
const emit = defineEmits<Emits>();
const theme = useThemeStore();
const active = computed({
get() {
return props.value;
},
set(val: string) {
emit('update:value', val);
}
});
/** 鼠标移入 */
async function handleMouse(item: AuthRoute.Route) {
active.value = item.path;
}
function handleTo() {
emit('enter');
}
</script>
<style lang="scss" scoped></style>

View File

@ -1,30 +0,0 @@
<template>
<div>
<hover-container
class="w-40px h-full"
tooltip-content="搜索"
:inverted="theme.header.inverted"
@click="handleSearch"
>
<icon-uil-search class="text-20px" />
</hover-container>
<search-modal v-model:value="show" />
</div>
</template>
<script lang="ts" setup>
import { useThemeStore } from '@/store';
import { useBoolean } from '@/hooks';
import { SearchModal } from './components';
defineOptions({ name: 'GlobalSearch' });
const { bool: show, toggle } = useBoolean();
const theme = useThemeStore();
function handleSearch() {
toggle();
}
</script>
<style lang="scss" scoped></style>

View File

@ -1,4 +0,0 @@
import VerticalSider from './vertical-sider/index.vue';
import VerticalMixSider from './vertical-mix-sider/index.vue';
export { VerticalSider, VerticalMixSider };

View File

@ -1,5 +0,0 @@
import MixMenuDetail from './mix-menu-detail.vue';
import MixMenuDrawer from './mix-menu-drawer.vue';
import MixMenuCollapse from './mix-menu-collapse.vue';
export { MixMenuDetail, MixMenuDrawer, MixMenuCollapse };

View File

@ -1,16 +0,0 @@
<template>
<n-button :text="true" class="h-36px" @click="app.toggleSiderCollapse">
<icon-ph-caret-double-right-bold v-if="app.siderCollapse" class="text-16px" />
<icon-ph-caret-double-left-bold v-else class="text-16px" />
</n-button>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/store';
defineOptions({ name: 'MixMenuCollapse' });
const app = useAppStore();
</script>
<style scoped></style>

View File

@ -1,48 +0,0 @@
<template>
<div class="mb-6px px-4px cursor-pointer" @mouseenter="setTrue" @mouseleave="setFalse">
<div
class="flex-center flex-col py-12px rounded-2px bg-transparent transition-colors duration-300 ease-in-out"
:class="{ 'text-primary !bg-primary_active': isActive, 'text-primary': isHover }"
>
<component :is="icon" :class="[isMini ? 'text-16px' : 'text-20px']" />
<p
class="w-full text-center ellipsis-text text-12px transition-height duration-300 ease-in-out"
:class="[isMini ? 'h-0 pt-0' : 'h-24px pt-4px']"
>
{{ label }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { useBoolean } from '@/hooks';
defineOptions({ name: 'MixMenuDetail' });
interface Props {
/** 路由名称 */
routeName: string;
/** 路由名称文本 */
label: string;
/** 当前激活状态的理由名称 */
activeRouteName: string;
/** 路由图标 */
icon?: Component;
/** mini尺寸的路由 */
isMini?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
icon: undefined,
isMini: false
});
const { bool: isHover, setTrue, setFalse } = useBoolean();
const isActive = computed(() => props.routeName === props.activeRouteName);
</script>
<style scoped></style>

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