This commit is contained in:
2025-12-16 20:20:53 +07:00
commit 2e651f1c89
315 changed files with 33529 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { createTextVNode, defineComponent } from 'vue';
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui';
defineOptions({
name: 'AppProvider'
});
const ContextHolder = defineComponent({
name: 'ContextHolder',
setup() {
function register() {
window.$loadingBar = useLoadingBar();
window.$dialog = useDialog();
window.$message = useMessage();
window.$notification = useNotification();
}
register();
return () => createTextVNode();
}
});
</script>
<template>
<NLoadingBarProvider>
<NDialogProvider>
<NNotificationProvider>
<NMessageProvider>
<ContextHolder />
<slot></slot>
</NMessageProvider>
</NNotificationProvider>
</NDialogProvider>
</NLoadingBarProvider>
</template>
<style scoped></style>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,42 @@
<script lang="ts" setup>
import { computed, useSlots } from 'vue';
import type { PopoverPlacement } from 'naive-ui';
defineOptions({ name: 'IconTooltip' });
interface Props {
icon?: string;
localIcon?: string;
desc?: string;
placement?: PopoverPlacement;
}
const props = withDefaults(defineProps<Props>(), {
icon: 'mdi-help-circle',
localIcon: '',
desc: '',
placement: 'top'
});
const slots = useSlots();
const hasCustomTrigger = computed(() => Boolean(slots.trigger));
if (!hasCustomTrigger.value && !props.icon && !props.localIcon) {
throw new Error('icon or localIcon is required when no custom trigger slot is provided');
}
</script>
<template>
<NTooltip :placement="placement">
<template #trigger>
<slot name="trigger">
<div class="cursor-pointer">
<SvgIcon :icon="icon" :local-icon="localIcon" />
</div>
</slot>
</template>
<slot>
<span>{{ desc }}</span>
</slot>
</NTooltip>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
defineOptions({
name: 'LangSwitch'
});
interface Props {
/** Current language */
lang: App.I18n.LangType;
/** Language options */
langOptions: App.I18n.LangOption[];
/** Show tooltip */
showTooltip?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showTooltip: true
});
type Emits = {
(e: 'changeLang', lang: App.I18n.LangType): void;
};
const emit = defineEmits<Emits>();
const tooltipContent = computed(() => {
if (!props.showTooltip) return '';
return $t('icon.lang');
});
/** Add bottom margin to all options except the last one for proper visual separation */
const dropdownOptions = computed(() => {
const lastIndex = props.langOptions.length - 1;
return props.langOptions.map((option, index) => ({
...option,
props: {
class: index < lastIndex ? 'mb-1' : undefined
}
}));
});
function changeLang(lang: App.I18n.LangType) {
emit('changeLang', lang);
}
</script>
<template>
<NDropdown :value="lang" :options="dropdownOptions" trigger="hover" @select="changeLang">
<div>
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
<SvgIcon icon="heroicons:language" />
</ButtonIcon>
</div>
</NDropdown>
</template>
<style scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { 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>