chore: init read-star monorepo

This commit is contained in:
2026-01-16 13:06:44 +08:00
commit 1e510efe8e
508 changed files with 44803 additions and 0 deletions

58
apps/admin/src/App.vue Normal file
View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import type { WatermarkProps } from 'naive-ui'
import { darkTheme, NConfigProvider } from 'naive-ui'
import { computed } from 'vue'
import { naiveDateLocales, naiveLocales } from './locales/naive'
import { useAppStore } from './store/modules/app'
import { useThemeStore } from './store/modules/theme'
defineOptions({
name: 'App',
})
const appStore = useAppStore()
const themeStore = useThemeStore()
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined))
const naiveLocale = computed(() => {
return naiveLocales[appStore.locale]
})
const naiveDateLocale = computed(() => {
return naiveDateLocales[appStore.locale]
})
const watermarkProps = computed<WatermarkProps>(() => {
return {
content: themeStore.watermarkContent,
cross: true,
fullscreen: true,
fontSize: 16,
lineHeight: 16,
width: 384,
height: 384,
xOffset: 12,
yOffset: 60,
rotate: -15,
zIndex: 9999,
}
})
</script>
<template>
<NConfigProvider
:theme="naiveDarkTheme"
:theme-overrides="themeStore.naiveTheme"
:locale="naiveLocale"
:date-locale="naiveDateLocale"
class="h-full"
>
<AppProvider>
<RouterView class="bg-layout" />
<NWatermark v-if="themeStore.watermark.visible" v-bind="watermarkProps" />
</AppProvider>
</NConfigProvider>
</template>
<style scoped></style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

View File

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

After

Width:  |  Height:  |  Size: 202 B

View File

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

After

Width:  |  Height:  |  Size: 315 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

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

After

Width:  |  Height:  |  Size: 345 B

View File

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

After

Width:  |  Height:  |  Size: 288 B

View File

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

After

Width:  |  Height:  |  Size: 283 B

View File

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

After

Width:  |  Height:  |  Size: 544 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 77 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 70 KiB

View File

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

After

Width:  |  Height:  |  Size: 309 B

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 74 KiB

View File

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

After

Width:  |  Height:  |  Size: 327 B

View File

@ -0,0 +1,45 @@
<script setup lang="ts" generic="T extends Record<string, unknown>, K = never">
import { VueDraggable } from 'vue-draggable-plus'
import { $t } from '@/locales'
defineOptions({
name: 'TableColumnSetting',
})
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
required: true,
})
</script>
<template>
<NPopover placement="bottom-end" trigger="click">
<template #trigger>
<NButton size="small">
<template #icon>
<icon-ant-design-setting-outlined class="text-icon" />
</template>
{{ $t('common.columnSetting') }}
</NButton>
</template>
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable">
<div
v-for="item in columns"
:key="item.key"
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
:class="{ hidden: !item.visible }"
>
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
<template v-if="typeof item.title === 'function'">
<component :is="item.title" />
</template>
<template v-else>
{{ item.title }}
</template>
</NCheckbox>
</div>
</VueDraggable>
</NPopover>
</template>
<style scoped></style>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { $t } from '@/locales'
defineOptions({
name: 'TableHeaderOperation',
})
defineProps<Props>()
const emit = defineEmits<Emits>()
interface Props {
itemAlign?: NaiveUI.Align
disabledDelete?: boolean
loading?: boolean
}
interface Emits {
(e: 'add'): void
(e: 'delete'): void
(e: 'refresh'): void
}
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
default: () => [],
})
function add() {
emit('add')
}
function batchDelete() {
emit('delete')
}
function refresh() {
emit('refresh')
}
</script>
<template>
<NSpace :align="itemAlign" wrap justify="end" class="lt-sm:w-200px">
<slot name="prefix" />
<slot name="default">
<NButton size="small" ghost type="primary" @click="add">
<template #icon>
<icon-ic-round-plus class="text-icon" />
</template>
{{ $t('common.add') }}
</NButton>
<NPopconfirm @positive-click="batchDelete">
<template #trigger>
<NButton size="small" ghost type="error" :disabled="disabledDelete">
<template #icon>
<icon-ic-round-delete class="text-icon" />
</template>
{{ $t('common.batchDelete') }}
</NButton>
</template>
{{ $t('common.confirmDelete') }}
</NPopconfirm>
</slot>
<NButton size="small" @click="refresh">
<template #icon>
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
</template>
{{ $t('common.refresh') }}
</NButton>
<TableColumnSetting v-model:columns="columns" />
<slot name="suffix" />
</NSpace>
</template>
<style scoped></style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui'
import { createTextVNode, defineComponent } from 'vue'
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 />
</NMessageProvider>
</NNotificationProvider>
</NDialogProvider>
</NLoadingBarProvider>
</template>
<style scoped></style>

View File

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

View File

@ -0,0 +1,45 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useRouterPush } from '@/hooks/common/router'
import { $t } from '@/locales'
defineOptions({ name: 'ExceptionBase' })
const props = defineProps<Props>()
type ExceptionType = '403' | '404' | '500'
interface Props {
/**
* Exception type
*
* - 403: no permission
* - 404: not found
* - 500: service error
*/
type: ExceptionType
}
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',
})
defineProps<Props>()
interface Props {
full?: boolean
}
</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 type { PopoverPlacement } from 'naive-ui'
import { computed, useSlots } from 'vue'
defineOptions({ name: 'IconTooltip' })
const props = withDefaults(defineProps<Props>(), {
icon: 'mdi-help-circle',
localIcon: '',
desc: '',
placement: 'top',
})
interface Props {
icon?: string
localIcon?: string
desc?: string
placement?: PopoverPlacement
}
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,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { $t } from '@/locales'
defineOptions({
name: 'LangSwitch',
})
const props = withDefaults(defineProps<Props>(), {
showTooltip: true,
})
const emit = defineEmits<Emits>()
interface Props {
/** Current language */
lang: App.I18n.LangType
/** Language options */
langOptions: App.I18n.LangOption[]
/** Show tooltip */
showTooltip?: boolean
}
interface Emits {
(e: 'changeLang', lang: App.I18n.LangType): void
}
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' })
const props = withDefaults(defineProps<Props>(), {
arrowIcon: false,
zIndex: 98,
})
interface Props {
/** Show collapsed icon */
collapsed?: boolean
/** Arrow style icon */
arrowIcon?: boolean
zIndex?: number
}
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' })
const props = defineProps<Props>()
interface Props {
pin?: boolean
}
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',
})
defineProps<Props>()
interface Props {
loading?: boolean
}
</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,160 @@
<template>
<div class="app-logo">
<svg
width="100%"
height="100%"
version="1.1"
viewBox="0 0 1000 1000"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<g>
<path
d="M 200,866 C 100,866 50,779.4 100,692.8 L 200,519.6 C 220,485 240,490 265,499.6 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
fill="url(#LinearGradient)"
fill-rule="nonzero"
opacity="1"
stroke="none"
/>
<path
d="M 420,866 C 455,861 478,846 500,827 C 614,696 615,597 500,517 C 394,444 333,374 380,207.82 L 260,415.67 C 240.22,450 254.37,465.1 275.28,481.79 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
fill="url(#LinearGradient_2)"
fill-rule="nonzero"
opacity="1"
stroke="none"
/>
<path
d="M 500,517 C 394,444 333,374 380,207.82 L 400,173.2 C 367,295 421,350 603,428 C 572,440 524,474 500,517"
fill="url(#LinearGradient_3)"
fill-rule="nonzero"
opacity="1"
stroke="none"
/>
<path
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 572,440 524,474 500,517 C 615,597 614,696 500,827"
fill="url(#LinearGradient_4)"
fill-rule="nonzero"
opacity="1"
stroke="none"
/>
<path
d="M 400,173.2 C 367,295 421,350 603,428 C 690,389, 750,445 788,500 L 600,173.2 C 550,86.6 450,86.6 400,173.2"
fill="url(#LinearGradient_5)"
fill-rule="nonzero"
opacity="1"
stroke="none"
/>
<path
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 690,389, 750,445 788,500 C 816,554 797,606 750,640 L 500,827"
fill="url(#LinearGradient_6)"
fill-rule="nonzero"
opacity="1"
stroke="none"
/>
<path
d="M 788,500 C 816,554 797,606 750,640 L 500,827 C 497,851 513,862 540,866 L 800,866 C 900,866 950,779.4 900,692.8 L 788,500"
fill="url(#LinearGradient_7)"
fill-rule="nonzero"
opacity="1"
stroke="none"
/>
</g>
<defs>
<linearGradient
id="LinearGradient"
gradientTransform="matrix(104.391 -73.3432 73.3432 104.391 277.441 710.122)"
gradientUnits="userSpaceOnUse"
x1="0"
x2="1"
y1="0"
y2="0"
>
<stop offset="0" stop-color="var(--logo-color-700)" />
<stop offset="1" stop-color="var(--logo-color-600)" />
</linearGradient>
<linearGradient
id="LinearGradient_2"
gradientTransform="matrix(-173.747 557.324 -557.324 -173.747 508.829 258.172)"
gradientUnits="userSpaceOnUse"
x1="0"
x2="1"
y1="0"
y2="0"
>
<stop offset="0" stop-color="var(--logo-color-300)" />
<stop offset="1" stop-color="var(--logo-color-500)" />
</linearGradient>
<linearGradient
id="LinearGradient_3"
gradientTransform="matrix(157.951 295.666 -295.666 157.951 382.944 193.642)"
gradientUnits="userSpaceOnUse"
x1="0"
x2="1"
y1="0"
y2="0"
>
<stop offset="0" stop-color="var(--logo-color-600)" />
<stop offset="1" stop-color="var(--logo-color-700)" />
</linearGradient>
<linearGradient
id="LinearGradient_4"
gradientTransform="matrix(-44.3023 219.578 -219.578 -44.3023 619.69 469.652)"
gradientUnits="userSpaceOnUse"
x1="0"
x2="1"
y1="0"
y2="0"
>
<stop offset="0" stop-color="var(--logo-color-400)" />
<stop offset="1" stop-color="var(--logo-color-600)" />
</linearGradient>
<linearGradient
id="LinearGradient_5"
gradientTransform="matrix(125.52 334.256 -334.256 125.52 539.723 235.139)"
gradientUnits="userSpaceOnUse"
x1="0"
x2="1"
y1="0"
y2="0"
>
<stop offset="0" stop-color="var(--logo-color-500)" />
<stop offset="1" stop-color="var(--logo-color-300)" />
</linearGradient>
<linearGradient
id="LinearGradient_6"
gradientTransform="matrix(-241.23 357.206 -357.206 -241.23 754.054 449.312)"
gradientUnits="userSpaceOnUse"
x1="0"
x2="1"
y1="0"
y2="0"
>
<stop offset="0" stop-color="var(--logo-color-300)" />
<stop offset="1" stop-color="var(--logo-color-500)" />
</linearGradient>
<linearGradient
id="LinearGradient_7"
gradientTransform="matrix(125.978 210.065 -210.065 125.978 596.433 613.665)"
gradientUnits="userSpaceOnUse"
x1="0"
x2="1"
y1="0"
y2="0"
>
<stop offset="0" stop-color="var(--logo-color-700)" />
<stop offset="1" stop-color="var(--logo-color-600)" />
</linearGradient>
</defs>
</svg>
</div>
</template>
<style scoped>
.app-logo {
--logo-color-300: rgb(var(--primary-300-color));
--logo-color-400: rgb(var(--primary-400-color));
--logo-color-500: rgb(var(--primary-500-color));
--logo-color-600: rgb(var(--primary-600-color));
--logo-color-700: rgb(var(--primary-700-color));
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
defineOptions({
name: 'ReaderStarAvatar',
})
</script>
<template>
<div class="size-72px overflow-hidden rd-1/2">
<img src="@/assets/imgs/reader-star.jpg" class="size-full">
</div>
</template>
<style scoped></style>

View File

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

View File

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

View File

@ -0,0 +1,71 @@
import { transformRecordToOption } from '@/utils/common'
export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__'
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__'
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
light: 'theme.appearance.themeSchema.light',
dark: 'theme.appearance.themeSchema.dark',
auto: 'theme.appearance.themeSchema.auto',
}
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord)
export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> = {
'pwd-login': 'page.login.pwdLogin.title',
'code-login': 'page.login.codeLogin.title',
'register': 'page.login.register.title',
'reset-pwd': 'page.login.resetPwd.title',
'bind-wechat': 'page.login.bindWeChat.title',
}
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
'vertical': 'theme.layout.layoutMode.vertical',
'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
'horizontal': 'theme.layout.layoutMode.horizontal',
'top-hybrid-sidebar-first': 'theme.layout.layoutMode.top-hybrid-sidebar-first',
'top-hybrid-header-first': 'theme.layout.layoutMode.top-hybrid-header-first',
}
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord)
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
wrapper: 'theme.layout.content.scrollMode.wrapper',
content: 'theme.layout.content.scrollMode.content',
}
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord)
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
chrome: 'theme.layout.tab.mode.chrome',
button: 'theme.layout.tab.mode.button',
slider: 'theme.layout.tab.mode.slider',
}
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord)
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
'fade-slide': 'theme.layout.content.page.mode.fade-slide',
'fade': 'theme.layout.content.page.mode.fade',
'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
'fade-scale': 'theme.layout.content.page.mode.fade-scale',
'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
'zoom-out': 'theme.layout.content.page.mode.zoom-out',
'none': 'theme.layout.content.page.mode.none',
}
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord)
export const DARK_CLASS = 'dark'
export const watermarkTimeFormatOptions = [
{ label: 'YYYY-MM-DD HH:mm', value: 'YYYY-MM-DD HH:mm' },
{ label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' },
{ label: 'YYYY/MM/DD HH:mm', value: 'YYYY/MM/DD HH:mm' },
{ label: 'YYYY/MM/DD HH:mm:ss', value: 'YYYY/MM/DD HH:mm:ss' },
{ label: 'HH:mm', value: 'HH:mm' },
{ label: 'HH:mm:ss', value: 'HH:mm:ss' },
{ label: 'MM-DD HH:mm', value: 'MM-DD HH:mm' },
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
defineOptions({
name: 'GlobalFooter',
})
</script>
<template>
<DarkModeContainer class="h-full flex-center">
<a target="_blank" rel="noopener noreferrer">
Copyright MIT © 2021 reader-star
</a>
</DarkModeContainer>
</template>
<style scoped></style>

View File

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

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import type { VNode } from 'vue'
import { computed } from 'vue'
import { useSvgIcon } from '@/hooks/common/icon'
import { useRouterPush } from '@/hooks/common/router'
import { $t } from '@/locales'
import { useAuthStore } from '@/store/modules/auth'
defineOptions({
name: 'UserAvatar',
})
const authStore = useAuthStore()
const { routerPushByKey, toLogin } = useRouterPush()
const { SvgIconVNode } = useSvgIcon()
function loginOrRegister() {
toLogin()
}
type DropdownKey = 'logout'
type DropdownOption =
| {
key: DropdownKey
label: string
icon?: () => VNode
}
| {
type: 'divider'
key: string
}
const options = computed(() => {
const opts: DropdownOption[] = [
{
label: $t('common.logout'),
key: 'logout',
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 }),
},
]
return opts
})
function logout() {
window.$dialog?.info({
title: $t('common.tip'),
content: $t('common.logoutConfirm'),
positiveText: $t('common.confirm'),
negativeText: $t('common.cancel'),
onPositiveClick: () => {
authStore.resetStore()
},
})
}
function handleDropdown(key: DropdownKey) {
if (key === 'logout') {
logout()
}
else {
// If your other options are jumps from other routes, they will be directly supported here
routerPushByKey(key)
}
}
</script>
<template>
<NButton v-if="!authStore.isLogin" quaternary @click="loginOrRegister">
{{ $t('page.login.common.loginOrRegister') }}
</NButton>
<NDropdown v-else placement="bottom" trigger="click" :options="options" @select="handleDropdown">
<div>
<ButtonIcon>
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
</ButtonIcon>
</div>
</NDropdown>
</template>
<style scoped></style>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,185 @@
import type { RouteKey } from '@elegant-router/types'
import { useContext } from '@sa/hooks'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRouterPush } from '@/hooks/common/router'
import { useRouteStore } from '@/store/modules/route'
import { useThemeStore } from '@/store/modules/theme'
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu)
function useMixMenu() {
const route = useRoute()
const routeStore = useRouteStore()
const themeStore = useThemeStore()
const { selectedKey } = useMenu()
const { routerPushByKeyWithMetaQuery } = useRouterPush()
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus)
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
routeStore.menus.map((menu) => {
const { children: _, ...rest } = menu
return rest
}),
)
const activeFirstLevelMenuKey = ref('')
function setActiveFirstLevelMenuKey(key: string) {
activeFirstLevelMenuKey.value = key
}
function getActiveFirstLevelMenuKey() {
const [firstLevelRouteName] = selectedKey.value.split('_')
setActiveFirstLevelMenuKey(firstLevelRouteName)
}
const isActiveFirstLevelMenuHasChildren = computed(() => {
if (!activeFirstLevelMenuKey.value) {
return false
}
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value)
return Boolean(findItem?.children?.length)
})
function handleSelectFirstLevelMenu(key: RouteKey) {
setActiveFirstLevelMenuKey(key)
if (!isActiveFirstLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key)
}
}
const secondLevelMenus = computed<App.Global.Menu[]>(
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || [],
)
const activeSecondLevelMenuKey = ref('')
function setActiveSecondLevelMenuKey(key: string) {
activeSecondLevelMenuKey.value = key
}
function getActiveSecondLevelMenuKey() {
const keys = selectedKey.value.split('_')
if (keys.length < 2) {
setActiveSecondLevelMenuKey('')
return
}
const [firstLevelRouteName, level2SuffixName] = keys
const secondLevelRouteName = `${firstLevelRouteName}_${level2SuffixName}`
setActiveSecondLevelMenuKey(secondLevelRouteName)
}
const isActiveSecondLevelMenuHasChildren = computed(() => {
if (!activeSecondLevelMenuKey.value) {
return false
}
const findItem = secondLevelMenus.value.find(item => item.key === activeSecondLevelMenuKey.value)
return Boolean(findItem?.children?.length)
})
function handleSelectSecondLevelMenu(key: RouteKey) {
setActiveSecondLevelMenuKey(key)
if (!isActiveSecondLevelMenuHasChildren.value) {
routerPushByKeyWithMetaQuery(key)
}
}
const childLevelMenus = computed<App.Global.Menu[]>(
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || [],
)
const hasChildLevelMenus = computed(() => childLevelMenus.value.length > 0)
function getDeepestLevelMenuKey(): RouteKey | null {
if (!secondLevelMenus.value.length || !themeStore.sider.autoSelectFirstMenu) {
return null
}
const secondLevelFirstMenu = secondLevelMenus.value[0]
if (!secondLevelFirstMenu) {
return null
}
function findDeepest(menu: App.Global.Menu): RouteKey {
if (!menu.children?.length) {
return menu.routeKey
}
return findDeepest(menu.children[0])
}
return findDeepest(secondLevelFirstMenu)
}
function activeDeepestLevelMenuKey() {
const deepestLevelMenuKey = getDeepestLevelMenuKey()
if (!deepestLevelMenuKey)
return
// select the deepest second level menu
handleSelectSecondLevelMenu(deepestLevelMenuKey)
}
watch(
() => route.name,
() => {
getActiveFirstLevelMenuKey()
// if there are child level menus, get the active second level menu key
if (hasChildLevelMenus.value) {
getActiveSecondLevelMenuKey()
}
},
{ immediate: true },
)
return {
firstLevelMenus,
activeFirstLevelMenuKey,
setActiveFirstLevelMenuKey,
isActiveFirstLevelMenuHasChildren,
handleSelectFirstLevelMenu,
getActiveFirstLevelMenuKey,
secondLevelMenus,
activeSecondLevelMenuKey,
setActiveSecondLevelMenuKey,
isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey,
childLevelMenus,
hasChildLevelMenus,
getDeepestLevelMenuKey,
activeDeepestLevelMenuKey,
}
}
export function useMenu() {
const route = useRoute()
const selectedKey = computed(() => {
const { hideInMenu, activeMenu } = route.meta
const name = route.name as string
const routeName = (hideInMenu ? activeMenu : name) || name
return routeName
})
return {
selectedKey,
}
}

View File

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

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app'
import { useRouterPush } from '@/hooks/common/router'
import { useRouteStore } from '@/store/modules/route'
import { useMenu } from '../context'
defineOptions({
name: 'HorizontalMenu',
})
const routeStore = useRouteStore()
const { routerPushByKeyWithMetaQuery } = useRouterPush()
const { selectedKey } = useMenu()
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="selectedKey"
:options="routeStore.menus"
:indent="18"
responsive
@update:value="routerPushByKeyWithMetaQuery"
/>
</Teleport>
</template>
<style scoped></style>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import type { RouteKey } from '@elegant-router/types'
import { SimpleScrollbar } from '@sa/materials'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
import { useRouterPush } from '@/hooks/common/router'
import { useAppStore } from '@/store/modules/app'
import { useRouteStore } from '@/store/modules/route'
import { useThemeStore } from '@/store/modules/theme'
import { useMenu, useMixMenuContext } from '../context'
defineOptions({
name: 'TopHybridHeaderFirst',
})
const route = useRoute()
const appStore = useAppStore()
const themeStore = useThemeStore()
const routeStore = useRouteStore()
const { routerPushByKeyWithMetaQuery } = useRouterPush()
const {
firstLevelMenus,
secondLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
activeDeepestLevelMenuKey,
} = useMixMenuContext('TopHybridHeaderFirst')
const { selectedKey } = useMenu()
const expandedKeys = ref<string[]>([])
/**
* Handle first level menu select
* @param key RouteKey
*/
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key)
// if there are second level menus, select the deepest one by default
activeDeepestLevelMenuKey()
}
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = []
return
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value)
}
watch(
() => route.name,
() => {
updateExpandedKeys()
},
{ immediate: true },
)
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="activeFirstLevelMenuKey"
:options="firstLevelMenus"
:indent="18"
responsive
@update:value="handleSelectMenu"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:collapsed="appStore.siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22"
:options="secondLevelMenus"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</Teleport>
</template>
<style scoped></style>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import type { RouteKey } from '@elegant-router/types'
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
import { useRouterPush } from '@/hooks/common/router'
import { useAppStore } from '@/store/modules/app'
import { useThemeStore } from '@/store/modules/theme'
import FirstLevelMenu from '../components/first-level-menu.vue'
import { useMenu, useMixMenuContext } from '../context'
defineOptions({
name: 'TopHybridSidebarFirst',
})
const appStore = useAppStore()
const themeStore = useThemeStore()
const { routerPushByKeyWithMetaQuery } = useRouterPush()
const {
firstLevelMenus,
secondLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
activeDeepestLevelMenuKey,
} = useMixMenuContext('TopHybridSidebarFirst')
const { selectedKey } = useMenu()
/**
* Handle first level menu select
* @param key RouteKey
*/
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key)
// if there are second level menus, select the deepest one by default
activeDeepestLevelMenuKey()
}
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="selectedKey"
:options="secondLevelMenus"
:indent="18"
responsive
@update:value="routerPushByKeyWithMetaQuery"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full pt-2">
<FirstLevelMenu
:menus="firstLevelMenus"
:active-menu-key="activeFirstLevelMenuKey"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
/>
</div>
</Teleport>
</template>
<style scoped></style>

View File

@ -0,0 +1,175 @@
<script setup lang="ts">
import type { RouteKey } from '@elegant-router/types'
import { useBoolean } from '@sa/hooks'
import { SimpleScrollbar } from '@sa/materials'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
import { useRouterPush } from '@/hooks/common/router'
import { useAppStore } from '@/store/modules/app'
import { useRouteStore } from '@/store/modules/route'
import { useThemeStore } from '@/store/modules/theme'
import GlobalLogo from '../../global-logo/index.vue'
import FirstLevelMenu from '../components/first-level-menu.vue'
import { useMenu, useMixMenuContext } from '../context'
defineOptions({
name: 'VerticalHybridHeaderFirst',
})
const route = useRoute()
const appStore = useAppStore()
const themeStore = useThemeStore()
const routeStore = useRouteStore()
const { routerPushByKeyWithMetaQuery } = useRouterPush()
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean()
const {
firstLevelMenus,
activeFirstLevelMenuKey,
handleSelectFirstLevelMenu,
getActiveFirstLevelMenuKey,
secondLevelMenus,
activeSecondLevelMenuKey,
isActiveSecondLevelMenuHasChildren,
handleSelectSecondLevelMenu,
getActiveSecondLevelMenuKey,
childLevelMenus,
hasChildLevelMenus,
activeDeepestLevelMenuKey,
} = useMixMenuContext('VerticalHybridHeaderFirst')
const { selectedKey } = useMenu()
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted)
const showDrawer = computed(() => hasChildLevelMenus.value && (drawerVisible.value || appStore.mixSiderFixed))
function handleSelectMixMenu(key: RouteKey) {
handleSelectSecondLevelMenu(key)
if (isActiveSecondLevelMenuHasChildren.value) {
setDrawerVisible(true)
}
}
/**
* Handle second level menu selection based on autoSelectFirstMenu setting:
* - When disabled: Activate first second-level menu for display only, expand third-level menu if exists
* - When enabled: Navigate to the deepest menu automatically
*/
function handleSelectMenu(key: RouteKey) {
handleSelectFirstLevelMenu(key)
if (secondLevelMenus.value.length === 0)
return
const secondFirstMenuKey = secondLevelMenus.value[0].routeKey
// Case 1: autoSelectFirstMenu disabled - only activate menu for display
if (!themeStore.sider.autoSelectFirstMenu) {
// Check if there are third-level menus
const hasChildren = secondLevelMenus.value.find(menu => menu.key === secondFirstMenuKey)?.children?.length
// If there are third-level menus, expand them
if (hasChildren) {
handleSelectMixMenu(secondFirstMenuKey)
}
return
}
// Case 2: autoSelectFirstMenu enabled - navigate to deepest menu
activeDeepestLevelMenuKey()
setDrawerVisible(false)
}
function handleResetActiveMenu() {
setDrawerVisible(false)
if (!appStore.mixSiderFixed) {
getActiveFirstLevelMenuKey()
getActiveSecondLevelMenuKey()
}
}
const expandedKeys = ref<string[]>([])
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = []
return
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value)
}
watch(
() => route.name,
() => {
updateExpandedKeys()
},
{ immediate: true },
)
</script>
<template>
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
<NMenu
mode="horizontal"
:value="activeFirstLevelMenuKey"
:options="firstLevelMenus"
:indent="18"
responsive
@update:value="handleSelectMenu"
/>
</Teleport>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
<FirstLevelMenu
:menus="secondLevelMenus"
:active-menu-key="activeSecondLevelMenuKey"
:inverted="inverted"
:sider-collapse="appStore.siderCollapse"
:dark-mode="themeStore.darkMode"
:theme-color="themeStore.themeColor"
@select="handleSelectMixMenu"
@toggle-sider-collapse="appStore.toggleSiderCollapse"
>
<GlobalLogo :show-title="false" :style="{ height: `${themeStore.header.height}px` }" />
</FirstLevelMenu>
<div
class="relative h-full transition-width-300"
:style="{
width: appStore.mixSiderFixed && hasChildLevelMenus ? `${themeStore.sider.mixChildMenuWidth}px` : '0px',
}"
>
<DarkModeContainer
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
:inverted="inverted"
:style="{ width: showDrawer ? `${themeStore.sider.mixChildMenuWidth}px` : '0px' }"
>
<header class="flex-y-center justify-between px-12px" :style="{ height: `${themeStore.header.height}px` }">
<h2 class="text-16px text-primary font-bold">
{{ $t('system.title') }}
</h2>
<PinToggler
:pin="appStore.mixSiderFixed"
:class="{ 'text-white:88 !hover:text-white': inverted }"
@click="appStore.toggleMixSiderFixed"
/>
</header>
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:options="childLevelMenus"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</DarkModeContainer>
</div>
</div>
</Teleport>
</template>
<style scoped></style>

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import { SimpleScrollbar } from '@sa/materials'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app'
import { useRouterPush } from '@/hooks/common/router'
import { useAppStore } from '@/store/modules/app'
import { useRouteStore } from '@/store/modules/route'
import { useThemeStore } from '@/store/modules/theme'
import { useMenu } from '../context'
defineOptions({
name: 'VerticalMenu',
})
const route = useRoute()
const appStore = useAppStore()
const themeStore = useThemeStore()
const routeStore = useRouteStore()
const { routerPushByKeyWithMetaQuery } = useRouterPush()
const { selectedKey } = useMenu()
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted)
const expandedKeys = ref<string[]>([])
function updateExpandedKeys() {
if (appStore.siderCollapse || !selectedKey.value) {
expandedKeys.value = []
return
}
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value)
}
watch(
() => route.name,
() => {
updateExpandedKeys()
},
{ immediate: true },
)
</script>
<template>
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
<SimpleScrollbar>
<NMenu
v-model:expanded-keys="expandedKeys"
mode="vertical"
:value="selectedKey"
:collapsed="appStore.siderCollapse"
:collapsed-width="themeStore.sider.collapsedWidth"
:collapsed-icon-size="22"
:options="routeStore.menus"
:inverted="inverted"
:indent="18"
@update:value="routerPushByKeyWithMetaQuery"
/>
</SimpleScrollbar>
</Teleport>
</template>
<style scoped></style>

View File

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

View File

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

View File

@ -0,0 +1,130 @@
<script lang="ts" setup>
import { onKeyStroke, useDebounceFn } from '@vueuse/core'
import { computed, ref, shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import { $t } from '@/locales'
import { useAppStore } from '@/store/modules/app'
import { useRouteStore } from '@/store/modules/route'
import SearchFooter from './search-footer.vue'
import SearchResult from './search-result.vue'
defineOptions({ name: 'SearchModal' })
const router = useRouter()
const appStore = useAppStore()
const routeStore = useRouteStore()
const isMobile = computed(() => appStore.isMobile)
const keyword = ref('')
const activePath = ref('')
const resultOptions = shallowRef<App.Global.Menu[]>([])
const handleSearch = useDebounceFn(search, 300)
const visible = defineModel<boolean>('show', { required: true })
function search() {
resultOptions.value = routeStore.searchMenus.filter((menu) => {
const trimKeyword = keyword.value.toLocaleLowerCase().trim()
const title = (menu.i18nKey ? $t(menu.i18nKey) : menu.label).toLocaleLowerCase()
return trimKeyword && title.includes(trimKeyword)
})
activePath.value = resultOptions.value[0]?.routePath ?? ''
}
function handleClose() {
// handle with setTimeout to prevent user from seeing some operations
setTimeout(() => {
visible.value = false
resultOptions.value = []
keyword.value = ''
}, 200)
}
/** key up */
function handleUp() {
const { length } = resultOptions.value
if (length === 0)
return
const index = getActivePathIndex()
if (index === -1)
return
const activeIndex = index === 0 ? length - 1 : index - 1
activePath.value = resultOptions.value[activeIndex].routePath
}
/** key down */
function handleDown() {
const { length } = resultOptions.value
if (length === 0)
return
const index = getActivePathIndex()
if (index === -1)
return
const activeIndex = index === length - 1 ? 0 : index + 1
activePath.value = resultOptions.value[activeIndex].routePath
}
function getActivePathIndex() {
return resultOptions.value.findIndex(item => item.routePath === activePath.value)
}
/** key enter */
function handleEnter() {
if (resultOptions.value?.length === 0 || activePath.value === '')
return
handleClose()
router.push(activePath.value)
}
function registerShortcut() {
onKeyStroke('Escape', handleClose)
onKeyStroke('Enter', handleEnter)
onKeyStroke('ArrowUp', handleUp)
onKeyStroke('ArrowDown', handleDown)
}
registerShortcut()
</script>
<template>
<NModal
v-model:show="visible"
:segmented="{ footer: 'soft' }"
:closable="false"
preset="card"
auto-focus
footer-style="padding: 0; margin: 0"
class="fixed left-0 right-0"
:class="[isMobile ? 'size-full top-0px rounded-0' : 'w-630px top-50px']"
@after-leave="handleClose"
>
<NInputGroup>
<NInput v-model:value="keyword" clearable :placeholder="$t('common.keywordSearch')" @input="handleSearch">
<template #prefix>
<icon-uil-search class="text-15px text-#c2c2c2" />
</template>
</NInput>
<NButton v-if="isMobile" type="primary" ghost @click="handleClose">
{{ $t('common.cancel') }}
</NButton>
</NInputGroup>
<div class="mt-20px">
<NEmpty v-if="resultOptions.length === 0" :description="$t('common.noData')" />
<SearchResult v-else v-model:path="activePath" :options="resultOptions" @enter="handleEnter" />
</div>
<template #footer>
<SearchFooter v-if="!isMobile" />
</template>
</NModal>
</template>
<style lang="scss" scoped></style>

View File

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

View File

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

View File

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

View File

@ -0,0 +1,147 @@
<script setup lang="ts">
import type { VNode } from 'vue'
import { computed } from 'vue'
import { useSvgIcon } from '@/hooks/common/icon'
import { $t } from '@/locales'
import { useTabStore } from '@/store/modules/tab'
defineOptions({
name: 'ContextMenu',
})
const props = withDefaults(defineProps<Props>(), {
excludeKeys: () => [],
disabledKeys: () => [],
})
interface Props {
/** ClientX */
x: number
/** ClientY */
y: number
tabId: string
excludeKeys?: App.Global.DropdownKey[]
disabledKeys?: App.Global.DropdownKey[]
}
const visible = defineModel<boolean>('visible')
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs, fixTab, unfixTab, isTabRetain, homeTab } = useTabStore()
const { SvgIconVNode } = useSvgIcon()
interface DropdownOption {
key: App.Global.DropdownKey
label: string
icon?: () => VNode
disabled?: boolean
}
const options = computed(() => {
const opts: DropdownOption[] = [
{
key: 'closeCurrent',
label: $t('dropdown.closeCurrent'),
icon: SvgIconVNode({ icon: 'ant-design:close-outlined', fontSize: 18 }),
},
{
key: 'closeOther',
label: $t('dropdown.closeOther'),
icon: SvgIconVNode({ icon: 'ant-design:column-width-outlined', fontSize: 18 }),
},
{
key: 'closeLeft',
label: $t('dropdown.closeLeft'),
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-left', fontSize: 18 }),
},
{
key: 'closeRight',
label: $t('dropdown.closeRight'),
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-right', fontSize: 18 }),
},
{
key: 'closeAll',
label: $t('dropdown.closeAll'),
icon: SvgIconVNode({ icon: 'ant-design:line-outlined', fontSize: 18 }),
},
]
if (props.tabId !== homeTab?.id) {
if (isTabRetain(props.tabId)) {
opts.push({
key: 'unpin',
label: $t('dropdown.unpin'),
icon: SvgIconVNode({ icon: 'mdi:pin-off-outline', fontSize: 18 }),
})
}
else {
opts.push({
key: 'pin',
label: $t('dropdown.pin'),
icon: SvgIconVNode({ icon: 'mdi:pin-outline', fontSize: 18 }),
})
}
}
const { excludeKeys, disabledKeys } = props
const result = opts.filter(opt => !excludeKeys.includes(opt.key))
disabledKeys.forEach((key) => {
const opt = result.find(item => item.key === key)
if (opt) {
opt.disabled = true
}
})
return result
})
function hideDropdown() {
visible.value = false
}
const dropdownAction: Record<App.Global.DropdownKey, () => void> = {
closeCurrent() {
removeTab(props.tabId)
},
closeOther() {
clearTabs([props.tabId])
},
closeLeft() {
clearLeftTabs(props.tabId)
},
closeRight() {
clearRightTabs(props.tabId)
},
closeAll() {
clearTabs()
},
pin() {
fixTab(props.tabId)
},
unpin() {
unfixTab(props.tabId)
},
}
function handleDropdown(optionKey: App.Global.DropdownKey) {
dropdownAction[optionKey]?.()
hideDropdown()
}
</script>
<template>
<NDropdown
:show="visible"
placement="bottom-start"
trigger="manual"
:x="x"
:y="y"
:options="options"
@clickoutside="hideDropdown"
@select="handleDropdown"
/>
</template>
<style scoped></style>

View File

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

View File

@ -0,0 +1,104 @@
<script setup lang="ts">
import type { PopoverPlacement } from 'naive-ui'
import { themeLayoutModeRecord } from '@/constants/app'
import { $t } from '@/locales'
defineOptions({
name: 'LayoutModeCard',
})
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
interface Props {
/** Layout mode */
mode: UnionKey.ThemeLayoutMode
/** Disabled */
disabled?: boolean
}
interface Emits {
/** Layout mode change */
(e: 'update:mode', mode: UnionKey.ThemeLayoutMode): void
}
type LayoutConfig = Record<
UnionKey.ThemeLayoutMode,
{
placement: PopoverPlacement
menuClass: string
mainClass: string
}
>
const layoutConfig: LayoutConfig = {
'vertical': {
placement: 'bottom',
menuClass: 'w-1/3 h-full',
mainClass: 'w-2/3 h-3/4',
},
'vertical-mix': {
placement: 'bottom',
menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4',
},
'vertical-hybrid-header-first': {
placement: 'bottom',
menuClass: 'w-1/4 h-full',
mainClass: 'w-2/3 h-3/4',
},
'horizontal': {
placement: 'bottom',
menuClass: 'w-full h-1/4',
mainClass: 'w-full h-3/4',
},
'top-hybrid-sidebar-first': {
placement: 'bottom',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4',
},
'top-hybrid-header-first': {
placement: 'bottom',
menuClass: 'w-full h-1/4',
mainClass: 'w-2/3 h-3/4',
},
}
function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
if (props.disabled)
return
emit('update:mode', mode)
}
</script>
<template>
<div class="grid grid-cols-2 gap-x-16px gap-y-12px md:grid-cols-3">
<div
v-for="(item, key) in layoutConfig"
:key="key"
class="flex-col-center cursor-pointer"
@click="handleChangeMode(key)"
>
<IconTooltip :placement="item.placement">
<template #trigger>
<div
class="h-64px w-96px gap-6px rd-4px p-6px shadow ring-2 ring-transparent transition-all hover:ring-primary"
:class="{ '!ring-primary': mode === key }"
>
<div class="h-full w-full gap-1" :class="[key.includes('vertical') ? 'flex' : 'flex-col']">
<slot :name="key" />
</div>
</div>
</template>
{{ $t(`theme.layout.layoutMode.${key}_detail`) }}
</IconTooltip>
<p class="mt-8px text-12px">
{{ $t(themeLayoutModeRecord[key]) }}
</p>
</div>
</div>
</template>
<style scoped></style>

View File

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

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { $t } from '@/locales'
import { useAppStore } from '@/store/modules/app'
import AppearanceSettings from './modules/appearance/index.vue'
import ConfigOperation from './modules/config-operation.vue'
import GeneralSettings from './modules/general/index.vue'
import LayoutSettings from './modules/layout/index.vue'
import PresetSettings from './modules/preset/index.vue'
defineOptions({
name: 'ThemeDrawer',
})
const appStore = useAppStore()
const activeTab = ref('appearance')
const drawerWidth = computed(() => {
const width = 400
// On mobile devices, use 90% of viewport width with a maximum of 400px
if (appStore.isMobile) {
return `min(90vw, ${width}px)`
}
return width
})
</script>
<template>
<NDrawer v-model:show="appStore.themeDrawerVisible" display-directive="show" :width="drawerWidth">
<NDrawerContent :title="$t('theme.themeDrawerTitle')" :native-scrollbar="false" closable>
<NTabs v-model:value="activeTab" type="segment" size="medium" class="mb-16px">
<NTab name="appearance" :tab="$t('theme.tabs.appearance')" />
<NTab name="layout" :tab="$t('theme.tabs.layout')" />
<NTab name="general" :tab="$t('theme.tabs.general')" />
<NTab name="preset" :tab="$t('theme.tabs.preset')" />
</NTabs>
<div class="min-h-400px">
<KeepAlive>
<AppearanceSettings v-if="activeTab === 'appearance'" />
<LayoutSettings v-else-if="activeTab === 'layout'" />
<GeneralSettings v-else-if="activeTab === 'general'" />
<PresetSettings v-else-if="activeTab === 'preset'" />
</KeepAlive>
</div>
<template #footer>
<ConfigOperation />
</template>
</NDrawerContent>
</NDrawer>
</template>
<style scoped>
:deep(.n-tab) {
display: flex;
align-items: center;
gap: 8px;
}
:deep(.n-tab-pane) {
padding: 0;
}
</style>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import ThemeColor from './modules/theme-color.vue'
import ThemeRadius from './modules/theme-radius.vue'
import ThemeSchema from './modules/theme-schema.vue'
defineOptions({
name: 'AppearanceSettings',
})
</script>
<template>
<div class="flex-col-stretch gap-16px">
<ThemeSchema />
<ThemeColor />
<ThemeRadius />
</div>
</template>
<style scoped></style>

View File

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

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import SettingItem from '../../../components/setting-item.vue'
defineOptions({
name: 'ThemeRadius',
})
const themeStore = useThemeStore()
</script>
<template>
<NDivider>{{ $t('theme.appearance.themeRadius.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.appearance.themeRadius.title')">
<NInputNumber v-model:value="themeStore.themeRadius" size="small" :step="1" :min="0" :max="16" class="w-120px" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped></style>

View File

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

View File

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

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import GlobalSettings from './modules/global-settings.vue'
import WatermarkSettings from './modules/watermark-settings.vue'
defineOptions({
name: 'GeneralSettings',
})
</script>
<template>
<div class="flex-col-stretch gap-16px">
<GlobalSettings />
<WatermarkSettings />
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,39 @@
<script setup lang="ts">
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import SettingItem from '../../../components/setting-item.vue'
defineOptions({
name: 'GlobalSettings',
})
const themeStore = useThemeStore()
</script>
<template>
<NDivider>{{ $t('theme.general.title') }}</NDivider>
<SettingItem :label="$t('theme.general.multilingual.visible')">
<NSwitch v-model:value="themeStore.header.multilingual.visible" />
</SettingItem>
<SettingItem :label="$t('theme.general.globalSearch.visible')">
<NSwitch v-model:value="themeStore.header.globalSearch.visible" />
</SettingItem>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,71 @@
<script setup lang="ts">
import { computed } from 'vue'
import { watermarkTimeFormatOptions } from '@/constants/app'
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import SettingItem from '../../../components/setting-item.vue'
defineOptions({
name: 'WatermarkSettings',
})
const themeStore = useThemeStore()
const isWatermarkTextVisible = computed(
() => themeStore.watermark.visible && !themeStore.watermark.enableUserName && !themeStore.watermark.enableTime,
)
</script>
<template>
<NDivider>{{ $t('theme.general.watermark.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.general.watermark.visible')">
<NSwitch v-model:value="themeStore.watermark.visible" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="2" :label="$t('theme.general.watermark.enableUserName')">
<NSwitch :value="themeStore.watermark.enableUserName" @update:value="themeStore.setWatermarkEnableUserName" />
</SettingItem>
<SettingItem v-if="themeStore.watermark.visible" key="3" :label="$t('theme.general.watermark.enableTime')">
<NSwitch :value="themeStore.watermark.enableTime" @update:value="themeStore.setWatermarkEnableTime" />
</SettingItem>
<SettingItem
v-if="themeStore.watermark.visible && themeStore.watermark.enableTime"
key="4"
:label="$t('theme.general.watermark.timeFormat')"
>
<NSelect
v-model:value="themeStore.watermark.timeFormat"
:options="watermarkTimeFormatOptions"
size="small"
class="w-210px"
/>
</SettingItem>
<SettingItem v-if="isWatermarkTextVisible" key="5" :label="$t('theme.general.watermark.text')">
<NInput
v-model:value="themeStore.watermark.text"
autosize
type="text"
size="small"
class="w-120px"
placeholder="reader-starAdmin"
/>
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { useThemeStore } from '@/store/modules/theme'
import ContentSettings from './modules/content-settings.vue'
import FooterSettings from './modules/footer-settings.vue'
import HeaderSettings from './modules/header-settings.vue'
import LayoutMode from './modules/layout-mode.vue'
import SiderSettings from './modules/sider-settings.vue'
import TabSettings from './modules/tab-settings.vue'
defineOptions({
name: 'LayoutSettings',
})
const themeStore = useThemeStore()
</script>
<template>
<div class="flex-col-stretch gap-16px">
<LayoutMode />
<TabSettings />
<HeaderSettings />
<!-- The top menu mode does not have a sidebar -->
<SiderSettings v-if="themeStore.layout.mode !== 'horizontal'" />
<FooterSettings />
<ContentSettings />
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
import { themePageAnimationModeOptions, themeScrollModeOptions } from '@/constants/app'
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import { translateOptions } from '@/utils/common'
import SettingItem from '../../../components/setting-item.vue'
defineOptions({
name: 'ContentSettings',
})
const themeStore = useThemeStore()
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper')
</script>
<template>
<NDivider>{{ $t('theme.layout.content.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.content.scrollMode.title')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.content.scrollMode.tip')" />
</template>
<NSelect
v-model:value="themeStore.layout.scrollMode"
:options="translateOptions(themeScrollModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem key="2" :label="$t('theme.layout.content.page.animate')">
<NSwitch v-model:value="themeStore.page.animate" />
</SettingItem>
<SettingItem v-if="themeStore.page.animate" key="3" :label="$t('theme.layout.content.page.mode.title')">
<NSelect
v-model:value="themeStore.page.animateMode"
:options="translateOptions(themePageAnimationModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="isWrapperScrollMode" key="4" :label="$t('theme.layout.content.fixedHeaderAndTab')">
<NSwitch v-model:value="themeStore.fixedHeaderAndTab" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import SettingItem from '../../../components/setting-item.vue'
defineOptions({
name: 'FooterSettings',
})
const themeStore = useThemeStore()
const layoutMode = computed(() => themeStore.layout.mode)
const isWrapperScrollMode = computed(() => themeStore.layout.scrollMode === 'wrapper')
const isMixHorizontalMode = computed(() =>
['top-hybrid-sidebar-first', 'top-hybrid-header-first'].includes(layoutMode.value),
)
</script>
<template>
<NDivider>{{ $t('theme.layout.footer.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.footer.visible')">
<NSwitch v-model:value="themeStore.footer.visible" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && isWrapperScrollMode"
key="2"
:label="$t('theme.layout.footer.fixed')"
>
<NSwitch v-model:value="themeStore.footer.fixed" />
</SettingItem>
<SettingItem v-if="themeStore.footer.visible" key="3" :label="$t('theme.layout.footer.height')">
<NInputNumber v-model:value="themeStore.footer.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem
v-if="themeStore.footer.visible && isMixHorizontalMode"
key="4"
:label="$t('theme.layout.footer.right')"
>
<NSwitch v-model:value="themeStore.footer.right" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,47 @@
<script setup lang="ts">
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import SettingItem from '../../../components/setting-item.vue'
defineOptions({
name: 'HeaderSettings',
})
const themeStore = useThemeStore()
</script>
<template>
<NDivider>{{ $t('theme.layout.header.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.header.height')">
<NInputNumber v-model:value="themeStore.header.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem key="2" :label="$t('theme.layout.header.breadcrumb.visible')">
<NSwitch v-model:value="themeStore.header.breadcrumb.visible" />
</SettingItem>
<SettingItem
v-if="themeStore.header.breadcrumb.visible"
key="3"
:label="$t('theme.layout.header.breadcrumb.showIcon')"
>
<NSwitch v-model:value="themeStore.header.breadcrumb.showIcon" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import { $t } from '@/locales'
import { useAppStore } from '@/store/modules/app'
import { useThemeStore } from '@/store/modules/theme'
import LayoutModeCard from '../../../components/layout-mode-card.vue'
defineOptions({
name: 'LayoutMode',
})
const appStore = useAppStore()
const themeStore = useThemeStore()
</script>
<template>
<NDivider>{{ $t('theme.layout.layoutMode.title') }}</NDivider>
<LayoutModeCard v-model:mode="themeStore.layout.mode" :disabled="appStore.isMobile">
<template #vertical>
<div class="layout-sider h-full w-18px !bg-primary" />
<div class="vertical-wrapper">
<div class="layout-header bg-primary-200" />
<div class="layout-main" />
</div>
</template>
<template #vertical-mix>
<div class="layout-sider h-full w-8px !bg-primary" />
<div class="layout-sider h-full w-16px !bg-primary-300" />
<div class="vertical-wrapper">
<div class="layout-header bg-primary-200" />
<div class="layout-main" />
</div>
</template>
<template #vertical-hybrid-header-first>
<div class="layout-sider h-full w-8px !bg-primary" />
<div class="layout-sider h-full w-16px !bg-primary-300" />
<div class="vertical-wrapper">
<div class="layout-header bg-primary" />
<div class="layout-main" />
</div>
</template>
<template #horizontal>
<div class="layout-header !bg-primary" />
<div class="horizontal-wrapper">
<div class="layout-main" />
</div>
</template>
<template #top-hybrid-sidebar-first>
<div class="layout-header !bg-primary-300" />
<div class="horizontal-wrapper">
<div class="layout-sider w-18px !bg-primary" />
<div class="layout-main" />
</div>
</template>
<template #top-hybrid-header-first>
<div class="layout-header bg-primary" />
<div class="horizontal-wrapper">
<div class="layout-sider w-18px" />
<div class="layout-main" />
</div>
</template>
</LayoutModeCard>
</template>
<style scoped>
.layout-header {
--uno: h-16px rd-4px;
}
.layout-sider {
--uno: bg-primary-300 rd-4px;
}
.layout-main {
--uno: flex-1 bg-primary-200 rd-4px;
}
.vertical-wrapper {
--uno: flex-1 flex-col gap-6px;
}
.horizontal-wrapper {
--uno: flex-1 flex gap-6px;
}
</style>

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue'
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import SettingItem from '../../../components/setting-item.vue'
defineOptions({
name: 'SiderSettings',
})
const themeStore = useThemeStore()
const layoutMode = computed(() => themeStore.layout.mode)
const isMixLayoutMode = computed(() => layoutMode.value.includes('mix') || layoutMode.value.includes('hybrid'))
const isHybridLayoutMode = computed(() => layoutMode.value.includes('hybrid'))
</script>
<template>
<NDivider>{{ $t('theme.layout.sider.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem v-if="layoutMode === 'vertical'" key="1" :label="$t('theme.layout.sider.width')">
<NInputNumber v-model:value="themeStore.sider.width" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical'" key="2" :label="$t('theme.layout.sider.collapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.collapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="3" :label="$t('theme.layout.sider.mixWidth')">
<NInputNumber v-model:value="themeStore.sider.mixWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isMixLayoutMode" key="4" :label="$t('theme.layout.sider.mixCollapsedWidth')">
<NInputNumber v-model:value="themeStore.sider.mixCollapsedWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="layoutMode === 'vertical-mix'" key="5" :label="$t('theme.layout.sider.mixChildMenuWidth')">
<NInputNumber v-model:value="themeStore.sider.mixChildMenuWidth" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="isHybridLayoutMode" key="6" :label="$t('theme.layout.sider.autoSelectFirstMenu')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.sider.autoSelectFirstMenuTip')" />
</template>
<NSwitch v-model:value="themeStore.sider.autoSelectFirstMenu" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import { themeTabModeOptions } from '@/constants/app'
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import { translateOptions } from '@/utils/common'
import SettingItem from '../../../components/setting-item.vue'
defineOptions({
name: 'TabSettings',
})
const themeStore = useThemeStore()
</script>
<template>
<NDivider>{{ $t('theme.layout.tab.title') }}</NDivider>
<TransitionGroup tag="div" name="setting-list" class="flex-col-stretch gap-12px">
<SettingItem key="1" :label="$t('theme.layout.tab.visible')">
<NSwitch v-model:value="themeStore.tab.visible" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="2" :label="$t('theme.layout.tab.cache')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.tab.cacheTip')" />
</template>
<NSwitch v-model:value="themeStore.tab.cache" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="3" :label="$t('theme.layout.tab.height')">
<NInputNumber v-model:value="themeStore.tab.height" size="small" :step="1" class="w-120px" />
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="4" :label="$t('theme.layout.tab.mode.title')">
<NSelect
v-model:value="themeStore.tab.mode"
:options="translateOptions(themeTabModeOptions)"
size="small"
class="w-120px"
/>
</SettingItem>
<SettingItem v-if="themeStore.tab.visible" key="5" :label="$t('theme.layout.tab.closeByMiddleClick')">
<template #suffix>
<IconTooltip :desc="$t('theme.layout.tab.closeByMiddleClickTip')" />
</template>
<NSwitch v-model:value="themeStore.tab.closeTabByMiddleClick" />
</SettingItem>
</TransitionGroup>
</template>
<style scoped>
.setting-list-move,
.setting-list-enter-active,
.setting-list-leave-active {
--uno: transition-all-300;
}
.setting-list-enter-from,
.setting-list-leave-to {
--uno: opacity-0 -translate-x-30px;
}
.setting-list-leave-active {
--uno: absolute;
}
</style>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import ThemePreset from './modules/theme-preset.vue'
defineOptions({
name: 'PresetSettings',
})
</script>
<template>
<div class="flex-col-stretch gap-16px">
<ThemePreset />
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,165 @@
<script setup lang="ts">
import { defu } from 'defu'
import { computed } from 'vue'
import { $t } from '@/locales'
import { useThemeStore } from '@/store/modules/theme'
import { themeSettings } from '@/theme/settings'
defineOptions({
name: 'ThemePreset',
})
type ThemePreset = Pick<
App.Theme.ThemeSetting,
| 'themeScheme'
| 'grayscale'
| 'colourWeakness'
| 'recommendColor'
| 'themeColor'
| 'themeRadius'
| 'otherColor'
| 'isInfoFollowPrimary'
| 'layout'
| 'page'
| 'header'
| 'tab'
| 'fixedHeaderAndTab'
| 'sider'
| 'footer'
| 'watermark'
| 'tokens'
> & {
name: string
desc: string
i18nkey?: string
version: string
/** Optional NaiveUI theme overrides */
naiveui?: App.Theme.NaiveUIThemeOverride
}
const presetModules = import.meta.glob('@/theme/preset/*.json', { eager: true, import: 'default' })
const themeStore = useThemeStore()
// Extract preset data
const presets = computed(() =>
Object.entries(presetModules)
.map(([path, presetData]) => {
const fileName = path.split('/').pop()?.replace('.json', '') || ''
return {
id: fileName,
...(presetData as ThemePreset),
}
})
.sort((a, b) => {
if (a.name === 'default')
return -1
if (b.name === 'default')
return 1
return a.name.localeCompare(b.name)
}),
)
function getPresetName(preset: ThemePreset): string {
if (!preset.i18nkey)
return preset.name
try {
const key = `${preset.i18nkey}.name` as App.I18n.I18nKey
const translated = $t(key)
return translated !== key ? translated : preset.name
}
catch {
return preset.name
}
}
function getPresetDesc(preset: ThemePreset): string {
if (!preset.i18nkey)
return preset.desc
try {
const key = `${preset.i18nkey}.desc` as App.I18n.I18nKey
const translated = $t(key)
return translated !== key ? translated : preset.desc
}
catch {
return preset.desc
}
}
function applyPreset(preset: ThemePreset): void {
const mergedPreset = defu(preset, themeSettings)
const { themeScheme, grayscale, colourWeakness, layout, watermark, naiveui, ...rest } = mergedPreset
themeStore.setThemeScheme(themeScheme)
themeStore.setGrayscale(grayscale)
themeStore.setColourWeakness(colourWeakness)
themeStore.setThemeLayout(layout.mode)
themeStore.setWatermarkEnableUserName(watermark.enableUserName)
themeStore.setWatermarkEnableTime(watermark.enableTime)
Object.assign(themeStore, {
...rest,
layout: { ...themeStore.layout, scrollMode: layout.scrollMode },
page: { ...rest.page },
header: { ...rest.header },
tab: { ...rest.tab },
sider: { ...rest.sider },
footer: { ...rest.footer },
watermark: { ...watermark },
tokens: { ...rest.tokens },
})
// Apply NaiveUI theme overrides if present
themeStore.setNaiveThemeOverrides(naiveui)
window.$message?.success($t('theme.appearance.preset.applySuccess'))
}
</script>
<template>
<NDivider>{{ $t('theme.appearance.preset.title') }}</NDivider>
<div class="flex flex-col gap-3">
<div
v-for="preset in presets"
:key="preset.id"
class="border border-primary/10 rounded-lg border-solid bg-white/5 p-3 backdrop-blur-10 transition-all duration-300 hover:(shadow-md -translate-y-0.5)"
>
<div class="mb-2 flex items-center justify-between">
<div class="min-w-0 w-full flex flex-1 items-center justify-between gap-2">
<h5 class="m-0 truncate text-sm text-primary font-600">
{{ getPresetName(preset) }}
</h5>
<NBadge :value="`v${preset.version}`" type="info" size="small" class="flex-shrink-0 opacity-80" />
</div>
<NButton type="primary" size="tiny" ghost round class="ml-2 flex-shrink-0" @click="applyPreset(preset)">
{{ $t('theme.appearance.preset.apply') }}
</NButton>
</div>
<p class="line-clamp-2 mb-3 text-xs text-gray-500 leading-4">
{{ getPresetDesc(preset) }}
</p>
<div class="flex items-center justify-between">
<div class="flex gap-1">
<div
v-for="(color, key) in { primary: preset.themeColor, ...preset.otherColor }"
:key="key"
class="h-3 w-3 cursor-pointer border border-white/30 rounded-full transition-transform hover:scale-110"
:style="{ backgroundColor: color }"
:class="{ 'ring-1 ring-primary/50': key === 'primary' }"
:title="key"
/>
</div>
<div class="flex items-center gap-1">
<div class="text-lg">
{{ preset.themeScheme === 'dark' ? '🌙' : '☀️' }}
</div>
<div class="text-lg">
{{ preset.grayscale ? '🎨' : '' }}
</div>
</div>
</div>
</div>
</div>
</template>

View File

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

View File

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

View File

@ -0,0 +1,363 @@
const local: App.I18n.Schema = {
system: {
title: 'reader-starAdmin',
updateTitle: 'System Version Update Notification',
updateContent: 'A new version of the system has been detected. Do you want to refresh the page immediately?',
updateConfirm: 'Refresh immediately',
updateCancel: 'Later',
},
common: {
action: 'Action',
add: 'Add',
addSuccess: 'Add Success',
backToHome: 'Back to home',
batchDelete: 'Batch Delete',
cancel: 'Cancel',
close: 'Close',
check: 'Check',
expandColumn: 'Expand Column',
columnSetting: 'Column Setting',
config: 'Config',
confirm: 'Confirm',
delete: 'Delete',
deleteSuccess: 'Delete Success',
confirmDelete: 'Are you sure you want to delete?',
edit: 'Edit',
warning: 'Warning',
error: 'Error',
index: 'Index',
keywordSearch: 'Please enter keyword',
logout: 'Logout',
logoutConfirm: 'Are you sure you want to log out?',
lookForward: 'Coming soon',
modify: 'Modify',
modifySuccess: 'Modify Success',
noData: 'No Data',
operate: 'Operate',
pleaseCheckValue: 'Please check whether the value is valid',
refresh: 'Refresh',
reset: 'Reset',
search: 'Search',
switch: 'Switch',
tip: 'Tip',
trigger: 'Trigger',
update: 'Update',
updateSuccess: 'Update Success',
userCenter: 'User Center',
yesOrNo: {
yes: 'Yes',
no: 'No',
},
},
request: {
logout: 'Logout user after request failed',
logoutMsg: 'User status is invalid, please log in again',
logoutWithModal: 'Pop up modal after request failed and then log out user',
logoutWithModalMsg: 'User status is invalid, please log in again',
refreshToken: 'The requested token has expired, refresh the token',
tokenExpired: 'The requested token has expired',
},
theme: {
themeDrawerTitle: 'Theme Configuration',
tabs: {
appearance: 'Appearance',
layout: 'Layout',
general: 'General',
preset: 'Preset',
},
appearance: {
themeSchema: {
title: 'Theme Schema',
light: 'Light',
dark: 'Dark',
auto: 'Follow System',
},
grayscale: 'Grayscale',
colourWeakness: 'Colour Weakness',
themeColor: {
title: 'Theme Color',
primary: 'Primary',
info: 'Info',
success: 'Success',
warning: 'Warning',
error: 'Error',
followPrimary: 'Follow Primary',
},
themeRadius: {
title: 'Theme Radius',
},
recommendColor: 'Apply Recommended Color Algorithm',
recommendColorDesc: 'The recommended color algorithm refers to',
preset: {
title: 'Theme Presets',
apply: 'Apply',
applySuccess: 'Preset applied successfully',
default: {
name: 'Default Preset',
desc: 'Default theme preset with balanced settings',
},
dark: {
name: 'Dark Preset',
desc: 'Dark theme preset for night time usage',
},
compact: {
name: 'Compact Preset',
desc: 'Compact layout preset for small screens',
},
azir: {
name: 'Azir\'s Preset',
desc: 'It is a cold and elegant preset that Azir likes',
},
},
},
layout: {
layoutMode: {
'title': 'Layout Mode',
'vertical': 'Vertical Mode',
'horizontal': 'Horizontal Mode',
'vertical-mix': 'Vertical Mix Mode',
'vertical-hybrid-header-first': 'Left Hybrid Header-First',
'top-hybrid-sidebar-first': 'Top-Hybrid Sidebar-First',
'top-hybrid-header-first': 'Top-Hybrid Header-First',
'vertical_detail': 'Vertical menu layout, with the menu on the left and content on the right.',
'vertical-mix_detail':
'Vertical mix-menu layout, with the primary menu on the dark left side and the secondary menu on the lighter left side.',
'vertical-hybrid-header-first_detail':
'Left hybrid layout, with the primary menu at the top, the secondary menu on the dark left side, and the tertiary menu on the lighter left side.',
'horizontal_detail': 'Horizontal menu layout, with the menu at the top and content below.',
'top-hybrid-sidebar-first_detail':
'Top hybrid layout, with the primary menu on the left and the secondary menu at the top.',
'top-hybrid-header-first_detail':
'Top hybrid layout, with the primary menu at the top and the secondary menu on the left.',
},
tab: {
title: 'Tab Settings',
visible: 'Tab Visible',
cache: 'Tag Bar Info Cache',
cacheTip: 'One-click to open/close global keepalive',
height: 'Tab Height',
mode: {
title: 'Tab Mode',
slider: 'Slider',
chrome: 'Chrome',
button: 'Button',
},
closeByMiddleClick: 'Close Tab by Middle Click',
closeByMiddleClickTip: 'Enable closing tabs by clicking with the middle mouse button',
},
header: {
title: 'Header Settings',
height: 'Header Height',
breadcrumb: {
visible: 'Breadcrumb Visible',
showIcon: 'Breadcrumb Icon Visible',
},
},
sider: {
title: 'Sider Settings',
inverted: 'Dark Sider',
width: 'Sider Width',
collapsedWidth: 'Sider Collapsed Width',
mixWidth: 'Mix Sider Width',
mixCollapsedWidth: 'Mix Sider Collapse Width',
mixChildMenuWidth: 'Mix Child Menu Width',
autoSelectFirstMenu: 'Auto Select First Submenu',
autoSelectFirstMenuTip:
'When a first-level menu is clicked, the first submenu is automatically selected and navigated to the deepest level',
},
footer: {
title: 'Footer Settings',
visible: 'Footer Visible',
fixed: 'Fixed Footer',
height: 'Footer Height',
right: 'Right Footer',
},
content: {
title: 'Content Area Settings',
scrollMode: {
title: 'Scroll Mode',
tip: 'The theme scroll only scrolls the main part, the outer scroll can carry the header and footer together',
wrapper: 'Wrapper',
content: 'Content',
},
page: {
animate: 'Page Animate',
mode: {
'title': 'Page Animate Mode',
'fade': 'Fade',
'fade-slide': 'Slide',
'fade-bottom': 'Fade Zoom',
'fade-scale': 'Fade Scale',
'zoom-fade': 'Zoom Fade',
'zoom-out': 'Zoom Out',
'none': 'None',
},
},
fixedHeaderAndTab: 'Fixed Header And Tab',
},
},
general: {
title: 'General Settings',
watermark: {
title: 'Watermark Settings',
visible: 'Watermark Full Screen Visible',
text: 'Custom Watermark Text',
enableUserName: 'Enable User Name Watermark',
enableTime: 'Show Current Time',
timeFormat: 'Time Format',
},
multilingual: {
title: 'Multilingual Settings',
visible: 'Display multilingual button',
},
globalSearch: {
title: 'Global Search Settings',
visible: 'Display GlobalSearch button',
},
},
configOperation: {
copyConfig: 'Copy Config',
copySuccessMsg: 'Copy Success, Please replace the variable "themeSettings" in "src/theme/settings.ts"',
resetConfig: 'Reset Config',
resetSuccessMsg: 'Reset Success',
},
},
route: {
'login': 'Login',
'403': 'No Permission',
'404': 'Page Not Found',
'500': 'Server Error',
'iframe-page': 'Iframe',
'home': 'Home',
},
page: {
login: {
common: {
loginOrRegister: 'Login / Register',
userNamePlaceholder: 'Please enter user name',
phonePlaceholder: 'Please enter phone number',
codePlaceholder: 'Please enter verification code',
passwordPlaceholder: 'Please enter password',
confirmPasswordPlaceholder: 'Please enter password again',
codeLogin: 'Verification code login',
confirm: 'Confirm',
back: 'Back',
validateSuccess: 'Verification passed',
loginSuccess: 'Login successfully',
welcomeBack: 'Welcome back, {userName} !',
},
pwdLogin: {
title: 'Password Login',
rememberMe: 'Remember me',
forgetPassword: 'Forget password?',
register: 'Register',
otherAccountLogin: 'Other Account Login',
otherLoginMode: 'Other Login Mode',
superAdmin: 'Super Admin',
admin: 'Admin',
user: 'User',
},
codeLogin: {
title: 'Verification Code Login',
getCode: 'Get verification code',
reGetCode: 'Reacquire after {time}s',
sendCodeSuccess: 'Verification code sent successfully',
imageCodePlaceholder: 'Please enter image verification code',
},
register: {
title: 'Register',
agreement: 'I have read and agree to',
protocol: '《User Agreement》',
policy: '《Privacy Policy》',
},
resetPwd: {
title: 'Reset Password',
},
bindWeChat: {
title: 'Bind WeChat',
},
},
home: {
branchDesc:
'For the convenience of everyone in developing and updating the merge, we have streamlined the code of the main branch, only retaining the homepage menu, and the rest of the content has been moved to the example branch for maintenance. The preview address displays the content of the example branch.',
greeting: 'Good morning, {userName}, today is another day full of vitality!',
weatherDesc: 'Today is cloudy to clear, 20℃ - 25℃!',
projectCount: 'Project Count',
todo: 'Todo',
message: 'Message',
downloadCount: 'Download Count',
registerCount: 'Register Count',
schedule: 'Work and rest Schedule',
study: 'Study',
work: 'Work',
rest: 'Rest',
entertainment: 'Entertainment',
visitCount: 'Visit Count',
turnover: 'Turnover',
dealCount: 'Deal Count',
projectNews: {
title: 'Project News',
moreNews: 'More News',
desc1: 'reader-star created the open source project reader-star-admin on May 28, 2021!',
desc2: 'Yanbowe submitted a bug to reader-star-admin, the multi-tab bar will not adapt.',
desc3: 'reader-star is ready to do sufficient preparation for the release of reader-star-admin!',
desc4: 'reader-star is busy writing project documentation for reader-star-admin!',
desc5: 'reader-star just wrote some of the workbench pages casually, and it was enough to see!',
},
creativity: 'Creativity',
},
},
form: {
required: 'Cannot be empty',
userName: {
required: 'Please enter user name',
invalid: 'User name format is incorrect',
},
phone: {
required: 'Please enter phone number',
invalid: 'Phone number format is incorrect',
},
pwd: {
required: 'Please enter password',
invalid: '6-18 characters, including letters, numbers, and underscores',
},
confirmPwd: {
required: 'Please enter password again',
invalid: 'The two passwords are inconsistent',
},
code: {
required: 'Please enter verification code',
invalid: 'Verification code format is incorrect',
},
email: {
required: 'Please enter email',
invalid: 'Email format is incorrect',
},
},
dropdown: {
closeCurrent: 'Close Current',
closeOther: 'Close Other',
closeLeft: 'Close Left',
closeRight: 'Close Right',
closeAll: 'Close All',
pin: 'Pin Tab',
unpin: 'Unpin Tab',
},
icon: {
themeConfig: 'Theme Configuration',
themeSchema: 'Theme Schema',
lang: 'Switch Language',
fullscreen: 'Fullscreen',
fullscreenExit: 'Exit Fullscreen',
reload: 'Reload Page',
collapse: 'Collapse Menu',
expand: 'Expand Menu',
pin: 'Pin',
unpin: 'Unpin',
},
datatable: {
itemCount: 'Total {total} items',
},
}
export default local

View File

@ -0,0 +1,359 @@
const local: App.I18n.Schema = {
system: {
title: 'reader-star 管理系统',
updateTitle: '系统版本更新通知',
updateContent: '检测到系统有新版本发布,是否立即刷新页面?',
updateConfirm: '立即刷新',
updateCancel: '稍后再说',
},
common: {
action: '操作',
add: '新增',
addSuccess: '添加成功',
backToHome: '返回首页',
batchDelete: '批量删除',
cancel: '取消',
close: '关闭',
check: '勾选',
expandColumn: '展开列',
columnSetting: '列设置',
config: '配置',
confirm: '确认',
delete: '删除',
deleteSuccess: '删除成功',
confirmDelete: '确认删除吗?',
edit: '编辑',
warning: '警告',
error: '错误',
index: '序号',
keywordSearch: '请输入关键词搜索',
logout: '退出登录',
logoutConfirm: '确认退出登录吗?',
lookForward: '敬请期待',
modify: '修改',
modifySuccess: '修改成功',
noData: '无数据',
operate: '操作',
pleaseCheckValue: '请检查输入的值是否合法',
refresh: '刷新',
reset: '重置',
search: '搜索',
switch: '切换',
tip: '提示',
trigger: '触发',
update: '更新',
updateSuccess: '更新成功',
userCenter: '个人中心',
yesOrNo: {
yes: '是',
no: '否',
},
},
request: {
logout: '请求失败后登出用户',
logoutMsg: '用户状态失效,请重新登录',
logoutWithModal: '请求失败后弹出模态框再登出用户',
logoutWithModalMsg: '用户状态失效,请重新登录',
refreshToken: '请求的token已过期刷新token',
tokenExpired: 'token已过期',
},
theme: {
themeDrawerTitle: '主题配置',
tabs: {
appearance: '外观',
layout: '布局',
general: '通用',
preset: '预设',
},
appearance: {
themeSchema: {
title: '主题模式',
light: '亮色模式',
dark: '暗黑模式',
auto: '跟随系统',
},
grayscale: '灰色模式',
colourWeakness: '色弱模式',
themeColor: {
title: '主题颜色',
primary: '主色',
info: '信息色',
success: '成功色',
warning: '警告色',
error: '错误色',
followPrimary: '跟随主色',
},
themeRadius: {
title: '主题圆角',
},
recommendColor: '应用推荐算法的颜色',
recommendColorDesc: '推荐颜色的算法参照',
preset: {
title: '主题预设',
apply: '应用',
applySuccess: '预设应用成功',
default: {
name: '默认预设',
desc: 'reader-star 默认主题预设',
},
dark: {
name: '暗色预设',
desc: '适用于夜间使用的暗色主题预设',
},
compact: {
name: '紧凑型',
desc: '适用于小屏幕的紧凑布局预设',
},
azir: {
name: 'Azir的预设',
desc: '是 Azir 比较喜欢的莫兰迪色系冷淡风',
},
},
},
layout: {
layoutMode: {
'title': '布局模式',
'vertical': '左侧菜单模式',
'vertical-mix': '左侧菜单混合模式',
'vertical-hybrid-header-first': '左侧混合-顶部优先',
'horizontal': '顶部菜单模式',
'top-hybrid-sidebar-first': '顶部混合-侧边优先',
'top-hybrid-header-first': '顶部混合-顶部优先',
'vertical_detail': '左侧菜单布局,菜单在左,内容在右。',
'vertical-mix_detail': '左侧双菜单布局,一级菜单在左侧深色区域,二级菜单在左侧浅色区域。',
'vertical-hybrid-header-first_detail':
'左侧混合布局,一级菜单在顶部,二级菜单在左侧深色区域,三级菜单在左侧浅色区域。',
'horizontal_detail': '顶部菜单布局,菜单在顶部,内容在下方。',
'top-hybrid-sidebar-first_detail': '顶部混合布局,一级菜单在左侧,二级菜单在顶部。',
'top-hybrid-header-first_detail': '顶部混合布局,一级菜单在顶部,二级菜单在左侧。',
},
tab: {
title: '标签栏设置',
visible: '显示标签栏',
cache: '标签栏信息缓存',
cacheTip: '一键开启/关闭全局 keepalive',
height: '标签栏高度',
mode: {
title: '标签栏风格',
slider: '滑块风格',
chrome: '谷歌风格',
button: '按钮风格',
},
closeByMiddleClick: '鼠标中键关闭标签页',
closeByMiddleClickTip: '启用后可以使用鼠标中键点击标签页进行关闭',
},
header: {
title: '头部设置',
height: '头部高度',
breadcrumb: {
visible: '显示面包屑',
showIcon: '显示面包屑图标',
},
},
sider: {
title: '侧边栏设置',
inverted: '深色侧边栏',
width: '侧边栏宽度',
collapsedWidth: '侧边栏折叠宽度',
mixWidth: '混合布局侧边栏宽度',
mixCollapsedWidth: '混合布局侧边栏折叠宽度',
mixChildMenuWidth: '混合布局子菜单宽度',
autoSelectFirstMenu: '自动选择第一个子菜单',
autoSelectFirstMenuTip: '点击一级菜单时,自动选择并导航到第一个子菜单的最深层级',
},
footer: {
title: '底部设置',
visible: '显示底部',
fixed: '固定底部',
height: '底部高度',
right: '底部居右',
},
content: {
title: '内容区域设置',
scrollMode: {
title: '滚动模式',
tip: '主题滚动仅 main 部分滚动,外层滚动可携带头部底部一起滚动',
wrapper: '外层滚动',
content: '主体滚动',
},
page: {
animate: '页面切换动画',
mode: {
'title': '页面切换动画类型',
'fade-slide': '滑动',
'fade': '淡入淡出',
'fade-bottom': '底部消退',
'fade-scale': '缩放消退',
'zoom-fade': '渐变',
'zoom-out': '闪现',
'none': '无',
},
},
fixedHeaderAndTab: '固定头部和标签栏',
},
},
general: {
title: '通用设置',
watermark: {
title: '水印设置',
visible: '显示全屏水印',
text: '自定义水印文本',
enableUserName: '启用用户名水印',
enableTime: '显示当前时间',
timeFormat: '时间格式',
},
multilingual: {
title: '多语言设置',
visible: '显示多语言按钮',
},
globalSearch: {
title: '全局搜索设置',
visible: '显示全局搜索按钮',
},
},
configOperation: {
copyConfig: '复制配置',
copySuccessMsg: '复制成功,请替换 src/theme/settings.ts 中的变量 themeSettings',
resetConfig: '重置配置',
resetSuccessMsg: '重置成功',
},
},
route: {
'login': '登录',
'403': '无权限',
'404': '页面不存在',
'500': '服务器错误',
'iframe-page': '外链页面',
'home': '首页',
},
page: {
login: {
common: {
loginOrRegister: '登录 / 注册',
userNamePlaceholder: '请输入用户名',
phonePlaceholder: '请输入手机号',
codePlaceholder: '请输入验证码',
passwordPlaceholder: '请输入密码',
confirmPasswordPlaceholder: '请再次输入密码',
codeLogin: '验证码登录',
confirm: '确定',
back: '返回',
validateSuccess: '验证成功',
loginSuccess: '登录成功',
welcomeBack: '欢迎回来,{userName} ',
},
pwdLogin: {
title: '密码登录',
rememberMe: '记住我',
forgetPassword: '忘记密码?',
register: '注册账号',
otherAccountLogin: '其他账号登录',
otherLoginMode: '其他登录方式',
superAdmin: '超级管理员',
admin: '管理员',
user: '普通用户',
},
codeLogin: {
title: '验证码登录',
getCode: '获取验证码',
reGetCode: '{time}秒后重新获取',
sendCodeSuccess: '验证码发送成功',
imageCodePlaceholder: '请输入图片验证码',
},
register: {
title: '注册账号',
agreement: '我已经仔细阅读并接受',
protocol: '《用户协议》',
policy: '《隐私权政策》',
},
resetPwd: {
title: '重置密码',
},
bindWeChat: {
title: '绑定微信',
},
},
home: {
branchDesc:
'为了方便大家开发和更新合并我们对main分支的代码进行了精简只保留了首页菜单其余内容已移至example分支进行维护。预览地址显示的内容即为example分支的内容。',
greeting: '早安,{userName}, 今天又是充满活力的一天!',
weatherDesc: '今日多云转晴20℃ - 25℃!',
projectCount: '项目数',
todo: '待办',
message: '消息',
downloadCount: '下载量',
registerCount: '注册量',
schedule: '作息安排',
study: '学习',
work: '工作',
rest: '休息',
entertainment: '娱乐',
visitCount: '访问量',
turnover: '成交额',
dealCount: '成交量',
projectNews: {
title: '项目动态',
moreNews: '更多动态',
desc1: 'reader-star 在2021年5月28日创建了开源项目 reader-star-admin!',
desc2: 'Yanbowe 向 reader-star-admin 提交了一个bug多标签栏不会自适应。',
desc3: 'reader-star 准备为 reader-star-admin 的发布做充分的准备工作!',
desc4: 'reader-star 正在忙于为reader-star-admin写项目说明文档',
desc5: 'reader-star 刚才把工作台页面随便写了一些,凑合能看了!',
},
creativity: '创意',
},
},
form: {
required: '不能为空',
userName: {
required: '请输入用户名',
invalid: '用户名格式不正确',
},
phone: {
required: '请输入手机号',
invalid: '手机号格式不正确',
},
pwd: {
required: '请输入密码',
invalid: '密码格式不正确6-18位字符包含字母、数字、下划线',
},
confirmPwd: {
required: '请输入确认密码',
invalid: '两次输入密码不一致',
},
code: {
required: '请输入验证码',
invalid: '验证码格式不正确',
},
email: {
required: '请输入邮箱',
invalid: '邮箱格式不正确',
},
},
dropdown: {
closeCurrent: '关闭',
closeOther: '关闭其它',
closeLeft: '关闭左侧',
closeRight: '关闭右侧',
closeAll: '关闭所有',
pin: '固定标签',
unpin: '取消固定',
},
icon: {
themeConfig: '主题配置',
themeSchema: '主题模式',
lang: '切换语言',
fullscreen: '全屏',
fullscreenExit: '退出全屏',
reload: '刷新页面',
collapse: '折叠菜单',
expand: '展开菜单',
pin: '固定',
unpin: '取消固定',
},
datatable: {
itemCount: '共 {total} 条',
},
}
export default local

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