功能模块:添加布局、登录和仪表盘

- 添加页面布局组件(src/layouts/)
  - 主布局容器
  - 头部导航栏
  - 侧边菜单栏
  - 标签页栏
  - 页脚组件
- 添加登录模块(src/views/login/)
  - 登录表单
  - 验证码组件
  - 登录背景动画
- 添加错误页面(src/views/error/)
  - 404页面
  - 500页面
- 添加仪表盘页面(src/views/dashboard/)
  - 数据概览
  - 图表展示
- 添加静态资源(src/assets/)
- 添加全局样式(src/styles/)
- 添加Vue模块配置(src/modules/)
This commit is contained in:
Leo 2025-10-08 02:28:25 +08:00
parent cb98681927
commit e8a78fa8b6
46 changed files with 4913 additions and 0 deletions

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1678514274388" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1083" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M813.696 813.738667a426.666667 426.666667 0 1 0-603.434667 0 426.666667 426.666667 0 0 0 603.434667 0z" fill="#FFD264" p-id="1084"></path><path d="M735.232 147.797333A426.666667 426.666667 0 0 1 152.448 741.333333 426.666667 426.666667 0 1 0 735.232 147.797333z" fill="#FFC656" p-id="1085"></path><path d="M143.36 556.970667A396.16 396.16 0 0 1 853.333333 315.477333 396.202667 396.202667 0 1 0 195.968 754.432a393.898667 393.898667 0 0 1-52.608-197.461333z" fill="#FFD781" p-id="1086"></path><path d="M337.066667 605.866667a14.037333 14.037333 0 0 0-23.296 5.930666 14.037333 14.037333 0 0 0 0.426666 9.429334 213.845333 213.845333 0 0 0 395.477334 3.882666 14.464 14.464 0 0 0 0.469333-9.386666 14.037333 14.037333 0 0 0-23.210667-6.101334c-62.378667 60.501333-194.944 146.645333-349.866666-3.754666z" fill="#62422A" p-id="1087"></path><path d="M438.656 451.754667a42.197333 42.197333 0 0 0 8.533333-25.088c0-22.186667-18.730667-41.344-45.824-50.176h208.085334v75.264zM333.482667 376.490667h-5.632v2.005333c1.834667-0.725333 3.712-1.408 5.632-2.005333z" fill="#2D292A" p-id="1088"></path><path d="M303.317333 437.461333m-155.605333 0a155.605333 155.605333 0 1 0 311.210667 0 155.605333 155.605333 0 1 0-311.210667 0Z" fill="#474549" p-id="1089"></path><path d="M298.325333 421.632m-155.605333 0a155.605333 155.605333 0 1 0 311.210667 0 155.605333 155.605333 0 1 0-311.210667 0Z" fill="#2D292A" p-id="1090"></path><path d="M704.896 437.461333m-155.605333 0a155.605333 155.605333 0 1 0 311.210666 0 155.605333 155.605333 0 1 0-311.210666 0Z" fill="#474549" p-id="1091"></path><path d="M699.904 425.898667m-155.605333 0a155.605333 155.605333 0 1 0 311.210666 0 155.605333 155.605333 0 1 0-311.210666 0Z" fill="#2D292A" p-id="1092"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 71 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1,15 @@
<template>
<n-back-top :bottom="80" :visibility-height="300">
<n-tooltip placement="left" trigger="hover">
<template #trigger>
<div wh-full flex-center>
<icon-park-outline-to-top />
</div>
</template>
<span>{{ $t('app.backTop') }}</span>
</n-tooltip>
</n-back-top>
</template>
<script setup lang="ts">
</script>

View File

@ -0,0 +1,72 @@
<template>
<div class="flex-center gap-4">
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'leftMenu',
}"
class="grid grid-cols-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'leftMenu'"
>
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.leftMenu') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'topMenu',
}"
class="grid grid-rows-[30%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'topMenu'"
>
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.topMenu') }} </span>
</n-tooltip>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<n-el
:class="{
'outline outline-2': value === 'mixMenu',
}"
class="grid grid-cols-[20%_1fr] grid-rows-[20%_1fr] outline-[var(--primary-color)] hover:(outline outline-2) cursor-pointer"
@click="value = 'mixMenu'"
>
<div class="bg-[var(--primary-color)] row-span-2" />
<div class="bg-[var(--primary-color)]" />
<div class="bg-[var(--divider-color)]" />
</n-el>
</template>
<span> {{ $t('app.mixMenu') }} </span>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
import type { LayoutMode } from '@/store/app'
const value = defineModel<LayoutMode>('value', { required: true })
</script>
<style lang="scss" scoped>
.grid{
height: 60px;
width: 86px;
gap:0.4em;
padding: 0.4em;
box-shadow: var(--box-shadow-1);
border-radius: var(--border-radius);
}
.grid > div{
border-radius: var(--border-radius);
}
</style>

View File

@ -0,0 +1,45 @@
<template>
<n-scrollbar style="height: 400px">
<n-list hoverable clickable>
<n-list-item v-for="(item) in list" :key="item.id" @click="emit('read', item.id)">
<n-thing content-indented :class="{ 'opacity-30': item.isRead }">
<template #header>
<n-ellipsis :line-clamp="1">
{{ item.title }}
</n-ellipsis>
</template>
<template #avatar>
<CoiIcon :icon="item.icon" :size="30" class="c-primary" />
</template>
<template v-if="item.tagTitle" #header-extra>
<n-tag :bordered="false" :type="item.tagType" size="small">
{{ item.tagTitle }}
</n-tag>
</template>
<template v-if="item.description" #description>
<n-ellipsis :line-clamp="2">
{{ item.description }}
</n-ellipsis>
</template>
<template #footer>
{{ item.date }}
</template>
</n-thing>
</n-list-item>
</n-list>
</n-scrollbar>
</template>
<script setup lang="ts">
interface Props {
list?: Entity.Message[]
}
const { list } = defineProps<Props>()
const emit = defineEmits<Emits>()
interface Emits {
(e: 'read', val: number): void
}
</script>
<style scoped></style>

View File

@ -0,0 +1,18 @@
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="appStore.showSetting = !appStore.showSetting">
<div>
<icon-park-outline-setting-two />
</div>
</CommonWrapper>
</template>
<span>{{ $t('app.setting') }}</span>
</n-tooltip>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
</script>

View File

@ -0,0 +1,148 @@
<template>
<n-drawer v-model:show="appStore.showSetting" :width="360">
<n-drawer-content :title="t('app.systemSetting')" closable>
<n-space vertical>
<n-divider>{{ $t('app.layoutSetting') }}</n-divider>
<LayoutSelector v-model:value="appStore.layoutMode" />
<n-divider>{{ $t('app.themeSetting') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.colorWeak') }}
<n-switch :value="appStore.colorWeak" @update:value="appStore.toggleColorWeak" />
</n-space>
<n-space justify="space-between">
{{ $t('app.blackAndWhite') }}
<n-switch :value="appStore.grayMode" @update:value="appStore.toggleGrayMode" />
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.themeColor') }}
<n-color-picker
v-model:value="appStore.primaryColor" class="w-10em" :swatches="palette"
@update:value="appStore.setPrimaryColor"
/>
</n-space>
<n-space align="center" justify="space-between">
{{ $t('app.pageTransition') }}
<n-select
v-model:value="appStore.transitionAnimation" class="w-10em"
:options="transitionSelectorOptions" @update:value="appStore.reloadPage"
/>
</n-space>
<n-divider>{{ $t('app.interfaceDisplay') }}</n-divider>
<n-space justify="space-between">
{{ $t('app.logoDisplay') }}
<n-switch v-model:value="appStore.showLogo" />
</n-space>
<n-space justify="space-between">
{{ $t('app.topProgress') }}
<n-switch v-model:value="appStore.showProgress" />
</n-space>
<n-space justify="space-between">
{{ $t('app.multitab') }}
<n-switch v-model:value="appStore.showTabs" />
</n-space>
<n-space justify="space-between">
{{ $t('app.bottomCopyright') }}
<n-switch v-model:value="appStore.showFooter" />
</n-space>
<n-space justify="space-between">
{{ $t('app.breadcrumb') }}
<n-switch v-model:value="appStore.showBreadcrumb" />
</n-space>
<n-space justify="space-between">
{{ $t('app.BreadcrumbIcon') }}
<n-switch v-model:value="appStore.showBreadcrumbIcon" />
</n-space>
<n-space justify="space-between">
菜单手风琴模式
<n-switch v-model:value="appStore.menuAccordion" />
</n-space>
</n-space>
<template #footer>
<n-button type="error" @click="resetSetting">
{{ $t('app.reset') }}
</n-button>
</template>
</n-drawer-content>
</n-drawer>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store'
import { coiMsgBox, coiMsgSuccess } from '@/utils/coi'
import LayoutSelector from './LayoutSelector.vue'
const appStore = useAppStore()
const { t } = useI18n()
const transitionSelectorOptions = computed(() => {
return [
{
label: t('app.transitionNull'),
value: '',
},
{
label: t('app.transitionFadeSlide'),
value: 'fade-slide',
},
{
label: t('app.transitionFadeBottom'),
value: 'fade-bottom',
},
{
label: t('app.transitionFadeScale'),
value: 'fade-scale',
},
{
label: t('app.transitionZoomFade'),
value: 'zoom-fade',
},
{
label: t('app.transitionZoomOut'),
value: 'zoom-out',
},
{
label: t('app.transitionSoft'),
value: 'fade',
},
]
})
const palette = [
'#0961BEFF',
'#0D9496',
'#7166F0',
'#8076C3',
'#ffda79',
'#18A058',
'#006266',
'#22a6b3',
'#18dcff',
'#2080F0',
'#c56cf0',
'#be2edd',
'#706fd3',
'#4834d4',
'#18A058',
'#FAE5AC',
]
async function resetSetting() {
try {
await coiMsgBox(
t('app.resetSettingContent'),
t('app.resetSettingTitle'),
t('common.confirm'),
t('common.cancel'),
'warning',
)
appStore.resetAlltheme()
coiMsgSuccess(t('app.resetSettingMeaasge'))
}
catch {
//
}
}
</script>

View File

@ -0,0 +1,50 @@
<template>
<TransitionGroup v-if="appStore.showBreadcrumb" name="list" tag="ul" style="display: flex; gap:1em;">
<n-el
v-for="(item) in routes"
:key="item.path"
tag="li" style="
color: var(--text-color-2);
transition: 0.3s var(--cubic-bezier-ease-in-out);
"
class="flex-center gap-2 cursor-pointer split"
@click="router.push(item.path)"
>
<CoiIcon v-if="appStore.showBreadcrumbIcon" :icon="item.meta.icon" />
<span class="whitespace-nowrap">{{ $t(`route.${String(item.name)}`, item.meta.title) }}</span>
</n-el>
</TransitionGroup>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store'
const router = useRouter()
const route = useRoute()
const routes = computed(() => {
return route.matched
})
const appStore = useAppStore()
</script>
<style lang="scss" scoped>
.split:not(:first-child)::before {
content: '/';
padding-right:0.6em;
}
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,.list-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.list-leave-active {
position: absolute;
}
</style>

View File

@ -0,0 +1,19 @@
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="appStore.toggleCollapse()">
<icon-park-outline-menu-unfold v-if="appStore.collapsed" />
<icon-park-outline-menu-fold v-else />
</CommonWrapper>
</template>
<span>{{ $t('app.toggleSider') }}</span>
</n-tooltip>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
</script>
<style scoped></style>

View File

@ -0,0 +1,27 @@
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="appStore.toggleFullScreen">
<icon-park-outline-off-screen v-if="appStore.fullScreen" />
<icon-park-outline-full-screen v-else />
</CommonWrapper>
</template>
<span>{{ $t('app.toggleFullScreen') }}</span>
</n-tooltip>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
useMagicKeys({
passive: false,
onEventFired(e) {
if (e.key === 'F11' && e.type === 'keydown') {
e.preventDefault()
appStore.toggleFullScreen()
}
},
})
</script>

View File

@ -0,0 +1,144 @@
<template>
<n-popover placement="bottom" trigger="click" arrow-point-to-center class="!p-0">
<template #trigger>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper>
<n-badge :value="massageCount" :max="99" style="color: unset">
<icon-park-outline-remind />
</n-badge>
</CommonWrapper>
</template>
<span>{{ $t('app.notificationsTips') }}</span>
</n-tooltip>
</template>
<n-tabs v-model:value="currentTab" type="line" animated justify-content="space-evenly" class="w-390px">
<n-tab-pane :name="0">
<template #tab>
<n-space class="w-130px" justify="center">
{{ $t('app.notifications') }}
<n-badge type="info" :value="groupMessage[0]?.filter(i => !i.isRead).length" :max="99" />
</n-space>
</template>
<NoticeList :list="groupMessage[0]" @read="handleRead" />
</n-tab-pane>
<n-tab-pane :name="1">
<template #tab>
<n-space class="w-130px" justify="center">
{{ $t('app.messages') }}
<n-badge type="warning" :value="groupMessage[1]?.filter(i => !i.isRead).length" :max="99" />
</n-space>
</template>
<NoticeList :list="groupMessage[1]" @read="handleRead" />
</n-tab-pane>
<n-tab-pane :name="2">
<template #tab>
<n-space class="w-130px" justify="center">
{{ $t('app.todos') }}
<n-badge type="error" :value="groupMessage[2]?.filter(i => !i.isRead).length" :max="99" />
</n-space>
</template>
<NoticeList :list="groupMessage[2]" @read="handleRead" />
</n-tab-pane>
</n-tabs>
</n-popover>
</template>
<script setup lang="ts">
import { group } from 'radash'
import { coiMsgSuccess } from '@/utils/coi'
import NoticeList from '../common/NoticeList.vue'
const MassageData = ref<Entity.Message[]>([
{
id: 0,
type: 0,
title: 'Admin 已经完成40%了!',
icon: 'icon-park-outline-tips-one',
tagTitle: '未开始',
tagType: 'info',
description: '项目稳定推进中,很快就能看到正式版了',
date: '2022-2-2 12:22',
},
{
id: 1,
type: 0,
title: 'Admin 已经添加通知功能!',
icon: 'icon-park-outline-comment-one',
tagTitle: '未开始',
tagType: 'success',
date: '2022-2-2 12:22',
},
{
id: 2,
type: 0,
title: 'Admin 已经添加路由功能!',
icon: 'icon-park-outline-message-emoji',
tagTitle: '未开始',
tagType: 'warning',
description: '项目稳定推进中...',
date: '2022-2-5 18:32',
},
{
id: 3,
type: 0,
title:
'Admin 已经添加菜单导航功能Admin 已经添加菜单导航功能Admin 已经添加菜单导航功能Admin 已经添加菜单导航功能!',
icon: 'icon-park-outline-tips-one',
tagTitle: '未开始',
tagType: 'error',
description:
'项目稳定推进中...项目稳定推进中...项目稳定推进中...项目稳定推进中...项目稳定推进中...项目稳定推进中...项目稳定推进中...',
date: '2022-2-5 18:32',
},
{
id: 4,
type: 0,
title: 'Admin开始启动了',
icon: 'icon-park-outline-tips-one',
tagTitle: '未开始',
description: '项目稳定推进中...',
date: '2022-2-5 18:32',
},
{
id: 5,
type: 1,
title: '相见恨晚??',
icon: 'icon-park-outline-comment',
description: '项目稳定推进中,很快就能看到正式版了',
date: '2022-2-2 12:22',
},
{
id: 6,
type: 1,
title: '动态路由已完成!',
icon: 'icon-park-outline-comment',
description: '项目稳定推进中,很快就能看到正式版了',
date: '2022-2-25 12:22',
},
{
id: 7,
type: 2,
title: '接下来需要完善一些',
icon: 'icon-park-outline-beach-umbrella',
tagTitle: '未开始',
description: '项目稳定推进中,很快就能看到正式版了',
date: '2022-2-2 12:22',
},
])
const currentTab = ref(0)
function handleRead(id: number) {
const data = MassageData.value.find(i => i.id === id)
if (data)
data.isRead = true
coiMsgSuccess(`id: ${id}`)
}
const massageCount = computed(() => {
return MassageData.value.filter(i => !i.isRead).length
})
const groupMessage = computed(() => {
return group(MassageData.value, i => i.type)
})
</script>
<style scoped></style>

View File

@ -0,0 +1,217 @@
<template>
<CommonWrapper @click="openModal">
<icon-park-outline-search /><n-tag round size="small" class="font-mono cursor-pointer">
CtrlK
</n-tag>
</CommonWrapper>
<n-modal
v-model:show="showModal"
class="w-560px fixed top-60px inset-x-0"
size="small"
preset="card"
:segmented="{
content: true,
footer: true,
}"
:closable="false"
@after-leave="handleClose"
>
<template #header>
<n-input v-model:value="searchValue" :placeholder="$t('app.searchPlaceholder')" clearable size="large" @input="handleInputChange">
<template #prefix>
<n-icon>
<icon-park-outline-search />
</n-icon>
</template>
</n-input>
</template>
<n-scrollbar ref="scrollbarRef" class="h-450px">
<ul
v-if="options.length"
class="flex flex-col gap-8px p-8px p-r-3"
>
<n-el
v-for="(option, index) in options"
:key="option.value" tag="li" role="option"
class="cursor-pointer shadow h-62px"
:class="{ 'text-[var(--base-color)] bg-[var(--primary-color-hover)]': index === selectedIndex }"
@click="handleSelect(option.value)"
@mouseenter="handleMouseEnter(index)"
@mousemove="setKeyboardFalse"
>
<div class="grid grid-rows-2 grid-cols-[40px_1fr_30px] h-full p-2">
<div class="row-span-2 place-self-center">
<CoiIcon :icon="option.icon" />
</div>
<span>{{ option.label }}</span>
<icon-park-outline-right class="row-span-2 place-self-center" />
<span class="op-70">{{ option.value }}</span>
</div>
</n-el>
</ul>
<n-empty v-else size="large" class="h-450px flex-center" />
</n-scrollbar>
<template #footer>
<n-flex>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Enter key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3" /></g></svg>
<span>{{ $t('common.choose') }}</span>
</div>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Arrow down" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3" /></g></svg>
<svg width="15" height="15" aria-label="Arrow up" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3" /></g></svg>
<span>{{ $t('common.navigate') }}</span>
</div>
<div class="flex-y-center gap-1">
<svg width="15" height="15" aria-label="Escape key" role="img"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2"><path d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956" /></g></svg>
<span>{{ $t('common.close') }}</span>
</div>
</n-flex>
</template>
</n-modal>
</template>
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { useRouteStore } from '@/store'
const routeStore = useRouteStore()
//
const searchValue = ref('')
//
const selectedIndex = ref<number>(0)
const { bool: showModal, setTrue: openModal, setFalse: closeModal, toggle: toggleModal } = useBoolean(false)
//
const { bool: keyboardFlag, setTrue: setKeyboardTrue, setFalse: setKeyboardFalse } = useBoolean(false)
const { ctrl_k, arrowup, arrowdown, enter/* keys you want to monitor */ } = useMagicKeys({
passive: false,
onEventFired(e) {
if (e.ctrlKey && e.key === 'k' && e.type === 'keydown')
e.preventDefault()
},
})
//
watchEffect(() => {
if (ctrl_k.value)
toggleModal()
})
const { t } = useI18n()
//
const options = computed(() => {
if (!searchValue.value)
return []
return routeStore.rowRoutes.filter((item) => {
const conditions = [
t(`route.${String(item.name)}`, item.title || item.name)?.includes(searchValue.value),
item.path?.includes(searchValue.value),
]
return conditions.some(condition => !item.hide && condition)
}).map((item) => {
return {
label: t(`route.${String(item.name)}`, item.title || item.name),
value: item.path,
icon: item.icon,
}
})
})
const router = useRouter()
//
function handleClose() {
searchValue.value = ''
selectedIndex.value = 0
closeModal()
}
//
function handleInputChange() {
selectedIndex.value = 0
}
//
function handleSelect(value: string) {
handleClose()
router.push(value)
nextTick(() => {
searchValue.value = ''
})
}
watchEffect(() => {
//
if (!showModal.value || !options.value.length)
return
// mouseover
setKeyboardTrue()
if (arrowup.value)
handleArrowup()
if (arrowdown.value)
handleArrowdown()
if (enter.value)
handleEnter()
})
const scrollbarRef = ref()
//
function handleArrowup() {
if (selectedIndex.value === 0)
selectedIndex.value = options.value.length - 1
else
selectedIndex.value--
handleScroll(selectedIndex.value)
}
//
function handleArrowdown() {
if (selectedIndex.value === options.value.length - 1)
selectedIndex.value = 0
else
selectedIndex.value++
handleScroll(selectedIndex.value)
}
function handleScroll(currentIndex: number) {
// 6,6
const keepIndex = 5
// gappadding
const elHeight = 70
const distance = currentIndex * elHeight > keepIndex * elHeight ? currentIndex * elHeight - keepIndex * elHeight : 0
scrollbarRef.value?.scrollTo({
top: distance,
})
}
//
function handleEnter() {
const target = options.value[selectedIndex.value]
if (target)
handleSelect(target.value)
}
//
function handleMouseEnter(index: number) {
if (keyboardFlag.value)
return
selectedIndex.value = index
}
</script>

View File

@ -0,0 +1,26 @@
import Setting from './common/Setting.vue'
import SettingDrawer from './common/SettingDrawer.vue'
import Breadcrumb from './header/Breadcrumb.vue'
import CollapaseButton from './header/CollapaseButton.vue'
import FullScreen from './header/FullScreen.vue'
import Notices from './header/Notices.vue'
import Search from './header/Search.vue'
import Logo from './sider/Logo.vue'
import Menu from './sider/Menu.vue'
import TabBar from './tab/TabBar.vue'
export {
Breadcrumb,
CollapaseButton,
FullScreen,
Logo,
Menu,
Notices,
Search,
Setting,
SettingDrawer,
TabBar,
}

View File

@ -0,0 +1,83 @@
<template>
<div
class="logo-container h-60px cursor-pointer p-x-3"
@click="router.push('/')"
>
<div class="logo-content">
<div class="logo-icon">
<svg-icons-logo class="text-2em" />
</div>
<div
v-show="!appStore.collapsed"
class="logo-text"
>
{{ name }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store'
const router = useRouter()
const appStore = useAppStore()
const name = import.meta.env.VITE_APP_NAME
</script>
<style scoped>
.logo-container {
display: flex;
align-items: center;
justify-content: flex-start;
transition: all 0.3s ease;
}
.logo-container:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
border-radius: 8px;
}
.logo-content {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
}
.logo-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
}
.logo-icon:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.logo-text {
font-size: 18px;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: all 0.3s ease;
}
.logo-container:hover .logo-text {
transform: translateX(2px);
}
</style>

View File

@ -0,0 +1,29 @@
<template>
<n-menu
ref="menuInstRef"
:collapsed="appStore.collapsed"
:indent="20"
:collapsed-width="64"
:options="routeStore.menus"
:value="routeStore.activeMenu"
:accordion="appStore.menuAccordion"
/>
</template>
<script setup lang="ts">
import type { MenuInst } from 'naive-ui'
import { useAppStore, useRouteStore } from '@/store'
const route = useRoute()
const appStore = useAppStore()
const routeStore = useRouteStore()
const menuInstRef = ref<MenuInst | null>(null)
watch(
() => route.path,
() => {
menuInstRef.value?.showOption(routeStore.activeMenu as string)
},
{ immediate: true },
)
</script>

View File

@ -0,0 +1,17 @@
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="appStore.contentFullScreen = !appStore.contentFullScreen">
<icon-park-outline-off-screen-one v-if="appStore.contentFullScreen" />
<icon-park-outline-full-screen-one v-else />
</CommonWrapper>
</template>
<span>{{ $t('app.togglContentFullScreen') }}</span>
</n-tooltip>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
</script>

View File

@ -0,0 +1,41 @@
<template>
<n-dropdown
:options="tabStore.allTabs"
:render-label="renderDropTabsLabel"
:render-icon="renderDropTabsIcon"
trigger="click"
size="small"
key-field="fullPath"
@select="handleDropTabs"
>
<CommonWrapper>
<icon-park-outline-application-menu />
</CommonWrapper>
</n-dropdown>
</template>
<script setup lang="ts">
import { useTabStore } from '@/store'
import { renderIcon } from '@/utils'
const tabStore = useTabStore()
const { t } = useI18n()
function renderDropTabsLabel(option: any) {
return t(`route.${String(option.name)}`, option.meta.title)
}
function renderDropTabsIcon(option: any) {
return renderIcon(option.meta.icon)!()
}
const router = useRouter()
function handleDropTabs(key: string, option: any) {
router.push(option.path)
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,28 @@
<template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>
<CommonWrapper @click="handleReload">
<icon-park-outline-refresh :class="{ 'animate-spin': loading }" />
</CommonWrapper>
</template>
<span>{{ $t('common.reload') }}</span>
</n-tooltip>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store'
const appStore = useAppStore()
const loading = ref(false)
function handleReload() {
loading.value = true
appStore.reloadPage()
setTimeout(() => {
loading.value = false
}, 800)
}
</script>
<style scoped></style>

View File

@ -0,0 +1,147 @@
<template>
<n-scrollbar ref="scrollbar" class="relative flex h-full tab-bar-scroller-wrapper" content-class="pr-34 tab-bar-scroller-content" :x-scrollable="true" @wheel="onWheel">
<div class="p-l-2 flex wh-full relative">
<div class="flex items-end">
<TabBarItem
v-for="item in tabStore.pinTabs" :key="item.fullPath" :value="tabStore.currentTabPath" :route="item"
@click="handleTab(item)"
/>
</div>
<div class="flex items-end flex-1">
<TabBarItem
v-for="item in tabStore.tabs"
:key="item.fullPath"
:value="tabStore.currentTabPath"
:route="item"
closable
:data-tab-path="item.fullPath"
@close="tabStore.closeTab"
@click="handleTab(item)"
@contextmenu="handleContextMenu($event, item)"
/>
<n-dropdown
placement="bottom-start" trigger="manual" :x="x" :y="y" :options="options" :show="showDropdown"
:on-clickoutside="onClickoutside" @select="handleSelect"
/>
</div>
</div>
<n-el class="absolute right-0 top-0 flex items-center gap-1 bg-[var(--card-color)] h-full">
<Reload />
<ContentFullScreen />
<DropTabs />
</n-el>
</n-scrollbar>
</template>
<script setup lang="ts">
import type { RouteLocationNormalized } from 'vue-router'
import { useAppStore, useTabStore } from '@/store'
import { useTabScroll } from '@/hooks/useTabScroll'
import IconClose from '~icons/icon-park-outline/close'
import IconDelete from '~icons/icon-park-outline/delete-four'
import IconFullwith from '~icons/icon-park-outline/fullwidth'
import IconRedo from '~icons/icon-park-outline/redo'
import IconLeft from '~icons/icon-park-outline/to-left'
import IconRight from '~icons/icon-park-outline/to-right'
import ContentFullScreen from './ContentFullScreen.vue'
import DropTabs from './DropTabs.vue'
import Reload from './Reload.vue'
import TabBarItem from './TabBarItem.vue'
const tabStore = useTabStore()
const appStore = useAppStore()
const { scrollbar, onWheel } = useTabScroll(computed(() => tabStore.currentTabPath))
const router = useRouter()
function handleTab(route: RouteLocationNormalized) {
router.push(route.fullPath)
}
const { t } = useI18n()
const options = computed(() => {
return [
{
label: t('common.reload'),
key: 'reload',
icon: () => h(IconRedo),
},
{
label: t('common.close'),
key: 'closeCurrent',
icon: () => h(IconClose),
},
{
label: t('app.closeOther'),
key: 'closeOther',
icon: () => h(IconDelete),
},
{
label: t('app.closeLeft'),
key: 'closeLeft',
icon: () => h(IconLeft),
},
{
label: t('app.closeRight'),
key: 'closeRight',
icon: () => h(IconRight),
},
{
label: t('app.closeAll'),
key: 'closeAll',
icon: () => h(IconFullwith),
},
]
})
const showDropdown = ref(false)
const x = ref(0)
const y = ref(0)
const currentRoute = ref()
function handleSelect(key: string) {
showDropdown.value = false
interface HandleFn {
[key: string]: any
}
const handleFn: HandleFn = {
reload() {
appStore.reloadPage()
},
closeCurrent() {
tabStore.closeTab(currentRoute.value.fullPath)
},
closeOther() {
tabStore.closeOtherTabs(currentRoute.value.fullPath)
},
closeLeft() {
tabStore.closeLeftTabs(currentRoute.value.fullPath)
},
closeRight() {
tabStore.closeRightTabs(currentRoute.value.fullPath)
},
closeAll() {
tabStore.closeAllTabs()
},
}
handleFn[key]()
}
function handleContextMenu(e: MouseEvent, route: RouteLocationNormalized) {
e.preventDefault()
currentRoute.value = route
showDropdown.value = false
nextTick().then(() => {
showDropdown.value = true
x.value = e.clientX
y.value = e.clientY
})
}
function onClickoutside() {
showDropdown.value = false
}
</script>
<style scoped>
.ghost {
opacity: 0.5;
background: #c4f6d5;
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<n-el
class="cursor-pointer p-x-4 p-y-2 m-x-2px b b-[--divider-color] b-b-[#0000] rounded-[--border-radius]"
:class="[
value === route.fullPath ? 'c-[--primary-color]' : 'c-[--text-color-2]',
value === route.fullPath ? 'bg-[#0000]' : 'bg-[--tab-color]',
closable && 'p-r-2',
]"
style="transition: box-shadow .3s var(--n-bezier), color .3s var(--n-bezier), background-color .3s var(--n-bezier), border-color .3s var(--n-bezier);"
>
<div class="flex-center gap-2 text-nowrap">
<CoiIcon :icon="route.meta.icon" />
<span>{{ $t(`route.${String(route.name)}`, route.meta.title) }}</span>
<button
v-if="closable"
type="button"
class="bg-transparent h-18px w-18px flex-center text-[var(--close-icon-color)] hover:bg-[var(--close-color-hover)] rounded-3px"
style="transition: background-color .3s var(--n-bezier), color .3s var(--n-bezier);"
@click.stop="emit('close', route.fullPath)"
>
<n-icon size="14">
<svg viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"><g fill="currentColor" fill-rule="nonzero"><path d="M2.08859116,2.2156945 L2.14644661,2.14644661 C2.32001296,1.97288026 2.58943736,1.95359511 2.7843055,2.08859116 L2.85355339,2.14644661 L6,5.293 L9.14644661,2.14644661 C9.34170876,1.95118446 9.65829124,1.95118446 9.85355339,2.14644661 C10.0488155,2.34170876 10.0488155,2.65829124 9.85355339,2.85355339 L6.707,6 L9.85355339,9.14644661 C10.0271197,9.32001296 10.0464049,9.58943736 9.91140884,9.7843055 L9.85355339,9.85355339 C9.67998704,10.0271197 9.41056264,10.0464049 9.2156945,9.91140884 L9.14644661,9.85355339 L6,6.707 L2.85355339,9.85355339 C2.65829124,10.0488155 2.34170876,10.0488155 2.14644661,9.85355339 C1.95118446,9.65829124 1.95118446,9.34170876 2.14644661,9.14644661 L5.293,6 L2.14644661,2.85355339 C1.97288026,2.67998704 1.95359511,2.41056264 2.08859116,2.2156945 L2.14644661,2.14644661 L2.08859116,2.2156945 Z" /></g></g></svg>
</n-icon>
</button>
</div>
</n-el>
</template>
<script setup lang="ts">
import type { RouteLocationNormalized } from 'vue-router'
const { route, value, closable = false } = defineProps<{
route: RouteLocationNormalized
value: string
closable?: boolean
}>()
const emit = defineEmits<{
close: [string]
}>()
</script>

19
src/layouts/index.vue Normal file
View File

@ -0,0 +1,19 @@
<template>
<SettingDrawer />
<component :is="layoutMap[appStore.layoutMode]" />
</template>
<script setup lang="ts">
import { useAppStore } from '@/store/app'
import { SettingDrawer } from './components'
import leftMenu from './leftMenu.layout.vue'
import mixMenu from './mixMenu.layout.vue'
import topMenu from './topMenu.layout.vue'
const appStore = useAppStore()
const layoutMap = {
leftMenu,
topMenu,
mixMenu,
}
</script>

View File

@ -0,0 +1,99 @@
<template>
<n-layout
has-sider
class="wh-full"
embedded
>
<n-layout-sider
v-if="!appStore.contentFullScreen"
bordered
:collapsed="appStore.collapsed"
collapse-mode="width"
:collapsed-width="64"
:width="240"
content-style="display: flex;flex-direction: column;min-height:100%;"
>
<Logo v-if="appStore.showLogo" />
<n-scrollbar class="flex-1">
<Menu />
</n-scrollbar>
</n-layout-sider>
<n-layout
class="h-full flex flex-col"
content-style="display: flex;flex-direction: column;min-height:100%;"
embedded
:native-scrollbar="false"
>
<n-layout-header bordered position="absolute" class="z-999">
<div v-if="!appStore.contentFullScreen" class="h-60px flex-y-center justify-between">
<div class="flex-y-center h-full">
<CollapaseButton />
<Breadcrumb />
</div>
<div class="flex-y-center gap-1 h-full p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</div>
</div>
<TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header>
<!-- 121 = 16 + 45 + 60 45是面包屑高度 60是标签栏高度 -->
<!-- 56 = 16 + 40 40是页脚高度 -->
<div
class="flex-1 p-16px flex flex-col"
:class="{
'p-t-121px': appStore.showTabs,
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
'p-t-76px': !appStore.showTabs,
'p-t-61px': appStore.contentFullScreen,
}"
>
<router-view v-slot="{ Component, route }" class="flex-1">
<transition
:name="appStore.transitionAnimation"
mode="out-in"
>
<keep-alive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="appStore.loadFlag"
:key="route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
</div>
<n-layout-footer
v-if="appStore.showFooter && !appStore.contentFullScreen"
bordered
position="absolute"
class="h-40px flex-center"
>
{{ appStore.footerText }}
</n-layout-footer>
</n-layout>
</n-layout>
</template>
<script lang="ts" setup>
import { useAppStore, useRouteStore } from '@/store'
import {
Breadcrumb,
CollapaseButton,
FullScreen,
Logo,
Menu,
Notices,
Search,
Setting,
TabBar,
} from './components'
const routeStore = useRouteStore()
const appStore = useAppStore()
</script>

View File

@ -0,0 +1,157 @@
<template>
<n-layout
has-sider
class="wh-full"
embedded
>
<n-layout-sider
v-if="!appStore.contentFullScreen"
bordered
:collapsed="appStore.collapsed"
collapse-mode="width"
:collapsed-width="64"
:width="240"
content-style="display: flex;flex-direction: column;min-height:100%;"
>
<Logo v-if="appStore.showLogo" />
<n-scrollbar class="flex-1">
<n-menu
ref="menuInstRef"
:collapsed="appStore.collapsed"
:indent="20"
:collapsed-width="64"
:options="sideMenu"
:value="routeStore.activeMenu"
/>
</n-scrollbar>
</n-layout-sider>
<n-layout
class="h-full flex flex-col"
content-style="display: flex;flex-direction: column;min-height:100%;"
embedded
:native-scrollbar="false"
>
<n-layout-header bordered position="absolute" class="z-999">
<div v-if="!appStore.contentFullScreen" class="h-60px flex-y-center justify-between">
<CollapaseButton />
<n-menu
ref="menuInstRef"
mode="horizontal"
responsive
:options="topMenu"
:value="activeTopMenu"
@update:value="updateTopMenu"
/>
<div class="flex-y-center gap-1 h-full p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</div>
</div>
<TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header>
<div
class="flex-1 p-16px flex flex-col"
:class="{
'p-t-121px': appStore.showTabs,
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
'p-t-76px': !appStore.showTabs,
'p-t-61px': appStore.contentFullScreen,
}"
>
<router-view v-slot="{ Component, route }" class="flex-1">
<transition
:name="appStore.transitionAnimation"
mode="out-in"
>
<keep-alive :include="routeStore.cacheRoutes">
<component
:is="Component"
v-if="appStore.loadFlag"
:key="route.fullPath"
/>
</keep-alive>
</transition>
</router-view>
</div>
<n-layout-footer
v-if="appStore.showFooter && !appStore.contentFullScreen"
bordered
position="absolute"
class="h-40px flex-center"
>
{{ appStore.footerText }}
</n-layout-footer>
</n-layout>
</n-layout>
</template>
<script lang="ts" setup>
import type { MenuInst, MenuOption } from 'naive-ui'
import { useAppStore, useRouteStore } from '@/store'
import {
CollapaseButton,
FullScreen,
Logo,
Notices,
Search,
Setting,
TabBar,
} from './components'
const routeStore = useRouteStore()
const appStore = useAppStore()
const pageRoute = useRoute()
const router = useRouter()
const menuInstRef = ref<MenuInst | null>(null)
watch(
() => pageRoute.path,
() => {
menuInstRef.value?.showOption(routeStore.activeMenu as string)
},
{ immediate: true },
)
const topMenu = ref<MenuOption[]>([])
const activeTopMenu = ref<string>('')
function handleTopMenu(rowMenu: MenuOption[]) {
topMenu.value = rowMenu.map((i) => {
const { icon, label, key } = i
return {
icon,
label,
key,
}
})
}
onMounted(() => {
handleTopMenu(routeStore.menus)
//
const currentMenuKey = pageRoute.matched[1].path
handleSideMenu(currentMenuKey)
activeTopMenu.value = currentMenuKey
})
const sideMenu = ref<MenuOption[]>([])
function handleSideMenu(key: string) {
const routeMenu = routeStore.menus as MenuOption[]
const targetMenu = routeMenu.find(i => i.key === key)
if (targetMenu) {
sideMenu.value = targetMenu.children ? targetMenu.children : [targetMenu]
}
}
function updateTopMenu(key: string) {
handleSideMenu(key)
activeTopMenu.value = key
router.push(key)
}
</script>

View File

@ -0,0 +1,64 @@
<template>
<n-layout class="wh-full" embedded>
<n-layout
class="h-full flex flex-col" content-style="display: flex;flex-direction: column;min-height:100%;"
embedded :native-scrollbar="false"
>
<n-layout-header bordered position="absolute" class="z-999">
<div v-if="!appStore.contentFullScreen" class="h-60px flex-y-center justify-between shrink-0">
<Logo v-if="appStore.showLogo" />
<Menu mode="horizontal" responsive />
<div class="flex-y-center gap-1 h-full p-x-xl">
<Search />
<Notices />
<FullScreen />
<DarkModeSwitch />
<LangsSwitch />
<Setting />
<UserCenter />
</div>
</div>
<TabBar v-if="appStore.showTabs" class="h-45px" />
</n-layout-header>
<div
class="flex-1 p-16px flex flex-col"
:class="{
'p-t-121px': appStore.showTabs,
'p-b-56px': appStore.showFooter && !appStore.contentFullScreen,
'p-t-76px': !appStore.showTabs,
'p-t-61px': appStore.contentFullScreen,
}"
>
<router-view v-slot="{ Component, route }" class="flex-1">
<transition :name="appStore.transitionAnimation" mode="out-in">
<keep-alive :include="routeStore.cacheRoutes">
<component :is="Component" v-if="appStore.loadFlag" :key="route.fullPath" />
</keep-alive>
</transition>
</router-view>
</div>
<n-layout-footer
v-if="appStore.showFooter && !appStore.contentFullScreen"
bordered position="absolute" class="h-40px flex-center"
>
{{ appStore.footerText }}
</n-layout-footer>
</n-layout>
</n-layout>
</template>
<script lang="ts" setup>
import { useAppStore, useRouteStore } from '@/store'
import {
FullScreen,
Logo,
Menu,
Notices,
Search,
Setting,
TabBar,
} from './components'
const routeStore = useRouteStore()
const appStore = useAppStore()
</script>

6
src/modules/assets.ts Normal file
View File

@ -0,0 +1,6 @@
import 'uno.css'
import '@/styles/index.css'
// 全局引入的静态资源
export function install() {
}

10
src/modules/directives.ts Normal file
View File

@ -0,0 +1,10 @@
import type { App } from 'vue'
export function install(app: App) {
/* 自动注册指令 */
Object.values(
import.meta.glob<{ install: (app: App) => void }>('@/directives/*.ts', {
eager: true,
}),
).map(i => app.use(i))
}

26
src/modules/i18n.ts Normal file
View File

@ -0,0 +1,26 @@
import type { App } from 'vue'
import { local } from '@/utils'
import { createI18n } from 'vue-i18n'
import enUS from '../../locales/en_US.json'
import zhCN from '../../locales/zh_CN.json'
const { VITE_DEFAULT_LANG } = import.meta.env
export const i18n = createI18n({
legacy: false,
locale: local.get('lang') || VITE_DEFAULT_LANG, // 默认显示语言
fallbackLocale: VITE_DEFAULT_LANG,
messages: {
zhCN,
enUS,
},
// 缺失国际化键警告
// missingWarn: false,
// 缺失回退内容警告
fallbackWarn: false,
})
export function install(app: App) {
app.use(i18n)
}

20
src/styles/index.css Normal file
View File

@ -0,0 +1,20 @@
@import './reset.css';
@import './transition.css';
@import './naive.css';
html,
body,
#app {
height: 100%;
}
.color-weak {
filter: invert(80%);
}
.gray-mode {
filter: grayscale(100%);
}
.drag-handle {
cursor: move;
}

14
src/styles/naive.css Normal file
View File

@ -0,0 +1,14 @@
.n-modal-mask {
backdrop-filter: blur(2px);
}
/* 解决tabs组件不贴合下边缘问题 */
.v-x-scroll {
height: 100%;
}
/* 解决二维码尺寸问题 */
.n-qr-code{
height: unset !important;
width: unset !important;;
}

403
src/styles/reset.css Normal file
View File

@ -0,0 +1,403 @@
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: currentColor;
/* 2 */
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
*/
html {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
/* 4 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font family by default.
2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-size: 100%;
/* 1 */
line-height: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
/* 1 */
/* background-color: transparent; 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role='button'] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
object-fit: cover;
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/*
Ensure the default browser behavior of the `hidden` attribute.
*/
[hidden] {
display: none;
}
.dark {
color-scheme: dark;
}

92
src/styles/transition.css Normal file
View File

@ -0,0 +1,92 @@
/* fade */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* fade-bottom */
.fade-bottom-enter-active,
.fade-bottom-leave-active {
transition: opacity 0.25s, transform 0.3s;
}
.fade-bottom-enter-from {
opacity: 0;
transform: translateY(-10%);
}
.fade-bottom-leave-to {
opacity: 0;
transform: translateY(10%);
}
/* fade-scale */
.fade-scale-leave-active,
.fade-scale-enter-active {
transition: all 0.28s;
}
.fade-scale-enter-from {
opacity: 0;
transform: scale(1.2);
}
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.8);
}
/* zoom-fade */
.zoom-fade-enter-active,
.zoom-fade-leave-active {
transition: transform 0.2s, opacity 0.3s ease-out;
}
.zoom-fade-enter-from {
opacity: 0;
transform: scale(0.92);
}
.zoom-fade-leave-to {
opacity: 0;
transform: scale(1.06);
}
/* zoom-out */
.zoom-out-enter-active,
.zoom-out-leave-active {
transition: opacity 0.1s ease-in-out, transform 0.15s ease-out;
}
.zoom-out-enter-from,
.zoom-out-leave-to {
opacity: 0;
transform: scale(0);
}
/* fade */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s, filter 0.2s ease-out;
}
.fade-enter-from {
opacity: 0;
filter: blur(10px);
}
.fade-leave-to {
opacity: 0;
filter: blur(0px);
}

View File

@ -0,0 +1,283 @@
<template>
<n-card class="stat-card" :style="cardStyle" hoverable>
<!-- 主要内容区域 -->
<n-space justify="space-between" align="center" class="mb-2">
<!-- 左侧数据区域 -->
<div class="stat-content">
<div class="stat-title">
{{ title }}
</div>
<div class="stat-value">
<n-number-animation
ref="numberAnimationRef"
:from="0"
:to="Number(value)"
:duration="1800"
show-separator
:active="true"
:precision="0"
:easing="cubicBezierEasing"
/>
</div>
<div class="stat-subtitle">
{{ subtitle }}
</div>
</div>
<!-- 右侧图标区域 -->
<div class="stat-icon" :style="iconStyle">
<n-icon :size="28">
<IconParkOutline:user v-if="icon === 'user'" />
<IconParkOutline:data v-else-if="icon === 'data'" />
<IconParkOutline:folderOpen v-else-if="icon === 'storage'" />
<IconParkOutline:chartLine v-else-if="icon === 'activity'" />
<IconParkOutline:user v-else />
</n-icon>
</div>
</n-space>
<!-- 底部信息区域 -->
<div class="stat-footer">
<n-space justify="space-between" align="center">
<div class="stat-extra">
{{ extraInfo }}
</div>
<div v-if="trend" class="stat-trend" :class="trendClass">
<n-icon :size="12" class="trend-icon">
<IconParkOutline:arrowUp v-if="trendUp" />
<IconParkOutline:arrowDown v-else />
</n-icon>
<span class="trend-text">{{ trend }}</span>
</div>
</n-space>
</div>
</n-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { StatCardProps } from '../types'
// Props
const props = withDefaults(defineProps<StatCardProps>(), {
trendUp: true,
})
//
const numberAnimationRef = ref()
//
function cubicBezierEasing(t: number): number {
return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1
}
// -
const cardStyle = computed(() => ({
background:
'linear-gradient(135deg, var(--card-color) 0%, rgba(255, 255, 255, 0.6) 100%)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08), 0 1px 4px rgba(0, 0, 0, 0.05)',
transition: 'all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
padding: '18px',
position: 'relative',
overflow: 'hidden',
minHeight: '140px',
}))
// -
const iconStyle = computed(() => ({
color: props.color,
background: `${props.color}12`,
borderRadius: '10px',
padding: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}))
//
const trendClass = computed(() => ({
'trend-up': props.trendUp,
'trend-down': !props.trendUp,
}))
//
onMounted(() => {
//
setTimeout(
() => {
numberAnimationRef.value?.play()
},
Math.random() * 300 + 200,
) //
})
</script>
<style scoped>
.stat-card {
cursor: pointer;
transition: all 0.3s ease;
border-radius: 16px;
overflow: hidden;
position: relative;
}
.stat-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
var(--primary-color),
var(--success-color),
var(--warning-color),
var(--info-color)
);
border-radius: 12px 12px 0 0;
z-index: 1;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color-suppl);
}
.stat-card:hover::before {
height: 3px;
}
.stat-content {
flex: 1;
}
.stat-title {
font-size: 14px;
color: var(--text-color-2);
margin-bottom: 8px;
font-weight: 500;
letter-spacing: 0.3px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: var(--text-color-1);
line-height: 1.2;
margin-bottom: 6px;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
.stat-subtitle {
font-size: 12px;
color: var(--text-color-3);
font-weight: 400;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.stat-card:hover .stat-icon {
transform: scale(1.08);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.stat-footer {
border-top: 1px solid var(--divider-color);
padding-top: 10px;
margin-top: 6px;
}
.stat-extra {
font-size: 11px;
color: var(--text-color-3);
font-weight: 400;
}
.stat-trend {
display: flex;
align-items: center;
gap: 3px;
font-size: 11px;
font-weight: 500;
padding: 3px 6px;
border-radius: 4px;
transition: all 0.3s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.trend-up {
color: var(--success-color);
background: linear-gradient(
135deg,
var(--success-color-suppl),
var(--success-color-hover)
);
}
.trend-down {
color: var(--error-color);
background: linear-gradient(
135deg,
var(--error-color-suppl),
var(--error-color-hover)
);
}
.trend-icon {
transition: transform 0.3s ease;
}
.trend-text {
font-weight: 600;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stat-value {
font-size: 24px;
}
.stat-icon {
width: 48px;
height: 48px;
}
.stat-icon :deep(.n-icon) {
font-size: 22px !important;
}
.stat-title {
font-size: 13px;
}
.stat-subtitle {
font-size: 11px;
}
.stat-extra {
font-size: 10px;
}
}
/* 暗色主题适配 */
@media (prefers-color-scheme: dark) {
.stat-card:hover {
box-shadow: 0 8px 24px rgba(255, 255, 255, 0.1);
}
}
</style>

View File

@ -0,0 +1,906 @@
<template>
<div class="login-trend-chart">
<!-- 图表标题和数据概览 -->
<div class="chart-header">
<div class="chart-stats">
<div class="stat-card highlight">
<div class="stat-icon">
<div class="icon-wrapper primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="m22 21-3-3m0 0a5.5 5.5 0 1 0-7.78-7.78 5.5 5.5 0 0 0 7.78 7.78Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>
<div class="stat-content">
<div class="stat-label">
今日登录
</div>
<div class="stat-value primary">
{{ todayCount.toLocaleString() }}
</div>
<div class="stat-subtitle">
实时数据
</div>
</div>
<div class="stat-trend up">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="m3 17 6-6 4 4 8-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="m14 5 7 0 0 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<div class="icon-wrapper secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2" />
<line x1="16" y1="2" x2="16" y2="6" stroke="currentColor" stroke-width="2" />
<line x1="8" y1="2" x2="8" y2="6" stroke="currentColor" stroke-width="2" />
<line x1="3" y1="10" x2="21" y2="10" stroke="currentColor" stroke-width="2" />
</svg>
</div>
</div>
<div class="stat-content">
<div class="stat-label">
昨日登录
</div>
<div class="stat-value">
{{ yesterdayCount.toLocaleString() }}
</div>
<div class="stat-subtitle">
对比数据
</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<div class="icon-wrapper" :class="growthClass.positive ? 'success' : 'warning'">
<svg v-if="growthClass.positive" width="20" height="20" viewBox="0 0 24 24" fill="none">
<line x1="12" y1="19" x2="12" y2="5" stroke="currentColor" stroke-width="2" />
<polyline points="5,12 12,5 19,12" stroke="currentColor" stroke-width="2" />
</svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<polyline points="19,12 12,19 5,12" stroke="currentColor" stroke-width="2" />
</svg>
</div>
</div>
<div class="stat-content">
<div class="stat-label">
环比增长
</div>
<div class="stat-value" :class="growthClass">
{{ growthRate }}
</div>
<div class="stat-subtitle">
{{ growthClass.positive ? '持续上升' : '有所下降' }}
</div>
</div>
</div>
</div>
</div>
<!-- 图表主体 -->
<div ref="chartContainer" class="chart-container">
<svg
class="chart-svg"
:width="chartWidth"
:height="chartHeight"
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
>
<!-- 网格线 -->
<g class="grid-lines">
<line
v-for="(line, index) in horizontalLines"
:key="`h-${index}`"
:x1="padding.left"
:x2="chartWidth - padding.right"
:y1="line.y"
:y2="line.y"
class="grid-line"
/>
<line
v-for="(line, index) in verticalLines"
:key="`v-${index}`"
:x1="line.x"
:x2="line.x"
:y1="padding.top"
:y2="chartHeight - padding.bottom"
class="grid-line"
/>
</g>
<!-- 渐变定义 -->
<defs>
<!-- 主要区域渐变 - 简洁的蓝色渐变 -->
<linearGradient id="areaGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#3b82f6" stop-opacity="0.3" />
<stop offset="100%" stop-color="#3b82f6" stop-opacity="0.05" />
</linearGradient>
</defs>
<!-- 面积填充 -->
<path
:d="areaPath"
fill="url(#areaGradient)"
class="area-fill"
/>
<!-- 趋势线 -->
<path
:d="smoothLinePath"
fill="none"
stroke="#3b82f6"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="trend-line"
/>
<!-- 数据点 -->
<g class="data-points">
<circle
v-for="(point, index) in chartPoints"
:key="index"
:cx="point.x"
:cy="point.y"
r="4"
fill="#3b82f6"
class="data-point"
@mouseenter="showTooltip(point, $event)"
@mouseleave="hideTooltip"
/>
</g>
<!-- Y轴标签 -->
<g class="y-axis-labels">
<text
v-for="(label, index) in yAxisLabels"
:key="index"
:x="padding.left - 10"
:y="label.y + 5"
text-anchor="end"
class="axis-label"
>
{{ label.value }}
</text>
</g>
<!-- X轴标签 -->
<g class="x-axis-labels">
<text
v-for="(label, index) in xAxisLabels"
:key="index"
:x="label.x"
:y="chartHeight - padding.bottom + 20"
text-anchor="middle"
class="axis-label"
>
{{ label.value }}
</text>
</g>
</svg>
<!-- 工具提示 -->
<transition name="tooltip-fade">
<div
v-if="tooltip.visible"
class="chart-tooltip"
:class="tooltip.position"
:style="tooltipStyle"
>
<div class="tooltip-content">
<div class="tooltip-date">
{{ tooltip.data?.label }}
</div>
<div class="tooltip-value">
<span class="tooltip-dot" :style="{ backgroundColor: primaryColor }" />
<span class="tooltip-text">登录次数: <strong>{{ tooltip.data?.count }}</strong></span>
</div>
</div>
<div class="tooltip-arrow" :class="tooltip.arrowPosition" />
</div>
</transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
// Props
interface LoginTrendData {
date: string
count: number
label: string
}
const props = defineProps<{
data: LoginTrendData[]
}>()
//
const chartContainer = ref<HTMLElement>()
const primaryColor = 'var(--primary-color)'
//
const chartWidth = 800
const chartHeight = 280
const padding = {
top: 20,
right: 30,
bottom: 50,
left: 60,
}
//
const tooltip = reactive({
visible: false,
x: 0,
y: 0,
data: null as LoginTrendData | null,
position: 'right' as 'left' | 'right',
arrowPosition: 'left' as 'left' | 'right' | 'top' | 'bottom',
})
//
const chartData = computed(() => props.data || [])
//
const todayCount = computed(() => {
const today = chartData.value[chartData.value.length - 1]
return today?.count || 0
})
const yesterdayCount = computed(() => {
const yesterday = chartData.value[chartData.value.length - 2]
return yesterday?.count || 0
})
//
const growthRate = computed(() => {
if (yesterdayCount.value === 0)
return '+0%'
const rate = ((todayCount.value - yesterdayCount.value) / yesterdayCount.value * 100)
return rate >= 0 ? `+${rate.toFixed(1)}%` : `${rate.toFixed(1)}%`
})
const growthClass = computed(() => ({
positive: todayCount.value >= yesterdayCount.value,
negative: todayCount.value < yesterdayCount.value,
}))
//
const dataRange = computed(() => {
const values = chartData.value.map(d => d.count)
const min = Math.min(...values)
const max = Math.max(...values)
const padding = (max - min) * 0.1
return {
min: Math.max(0, min - padding),
max: max + padding,
}
})
//
function getX(index: number): number {
const chartAreaWidth = chartWidth - padding.left - padding.right
return padding.left + (chartAreaWidth / (chartData.value.length - 1)) * index
}
function getY(value: number): number {
const chartAreaHeight = chartHeight - padding.top - padding.bottom
const range = dataRange.value.max - dataRange.value.min
return padding.top + chartAreaHeight - ((value - dataRange.value.min) / range) * chartAreaHeight
}
//
const chartPoints = computed(() => {
return chartData.value.map((item, index) => ({
x: getX(index),
y: getY(item.count),
data: item,
}))
})
// 线
const smoothLinePath = computed(() => {
if (chartPoints.value.length === 0)
return ''
const points = chartPoints.value
if (points.length === 1) {
return `M ${points[0].x} ${points[0].y}`
}
let path = `M ${points[0].x} ${points[0].y}`
for (let i = 1; i < points.length; i++) {
const current = points[i]
const previous = points[i - 1]
if (i === 1) {
// 使线
const controlX = previous.x + (current.x - previous.x) * 0.5
const controlY = previous.y
path += ` Q ${controlX} ${controlY} ${current.x} ${current.y}`
}
else {
// 使线
const controlPoint1X = previous.x + (current.x - previous.x) * 0.3
const controlPoint1Y = previous.y
const controlPoint2X = current.x - (current.x - previous.x) * 0.3
const controlPoint2Y = current.y
path += ` C ${controlPoint1X} ${controlPoint1Y} ${controlPoint2X} ${controlPoint2Y} ${current.x} ${current.y}`
}
}
return path
})
const areaPath = computed(() => {
if (chartPoints.value.length === 0)
return ''
const points = chartPoints.value
const bottom = chartHeight - padding.bottom
//
let path = `M ${points[0].x} ${bottom}`
path += ` L ${points[0].x} ${points[0].y}`
// 使线线
for (let i = 1; i < points.length; i++) {
const current = points[i]
const previous = points[i - 1]
if (i === 1) {
// 使线
const controlX = previous.x + (current.x - previous.x) * 0.5
const controlY = previous.y
path += ` Q ${controlX} ${controlY} ${current.x} ${current.y}`
}
else {
// 使线
const controlPoint1X = previous.x + (current.x - previous.x) * 0.3
const controlPoint1Y = previous.y
const controlPoint2X = current.x - (current.x - previous.x) * 0.3
const controlPoint2Y = current.y
path += ` C ${controlPoint1X} ${controlPoint1Y} ${controlPoint2X} ${controlPoint2Y} ${current.x} ${current.y}`
}
}
//
path += ` L ${points[points.length - 1].x} ${bottom} Z`
return path
})
// 线
const horizontalLines = computed(() => {
const lines = []
const steps = 4
for (let i = 0; i <= steps; i++) {
const value = dataRange.value.min + (dataRange.value.max - dataRange.value.min) * (i / steps)
lines.push({ y: getY(value) })
}
return lines
})
const verticalLines = computed(() => {
return chartData.value.map((_, index) => ({
x: getX(index),
}))
})
//
const yAxisLabels = computed(() => {
const labels = []
const steps = 4
for (let i = 0; i <= steps; i++) {
const value = dataRange.value.min + (dataRange.value.max - dataRange.value.min) * (i / steps)
labels.push({
y: getY(value),
value: Math.round(value).toLocaleString(),
})
}
return labels.reverse()
})
const xAxisLabels = computed(() => {
return chartData.value.map((item, index) => ({
x: getX(index),
value: item.label,
}))
})
//
const tooltipStyle = computed(() => ({
left: `${tooltip.x}px`,
top: `${tooltip.y}px`,
}))
//
function showTooltip(point: any, event: MouseEvent) {
const rect = chartContainer.value?.getBoundingClientRect()
if (!rect)
return
tooltip.visible = true
tooltip.data = point.data
// tooltip
const tooltipWidth = 140
const tooltipHeight = 70
const offset = 16
//
const mouseX = event.clientX - rect.left
const mouseY = event.clientY - rect.top
//
const isRightSide = mouseX > rect.width / 2
//
let x = mouseX + offset
tooltip.position = 'right'
tooltip.arrowPosition = 'left'
if (isRightSide) {
//
x = mouseX - tooltipWidth - offset
tooltip.position = 'left'
tooltip.arrowPosition = 'right'
}
//
x = Math.max(offset, Math.min(x, rect.width - tooltipWidth - offset))
//
let y = mouseY - tooltipHeight / 2
//
if (y < offset) {
y = mouseY + offset
tooltip.arrowPosition = 'top'
}
else if (y + tooltipHeight > rect.height - offset) {
y = mouseY - tooltipHeight - offset
tooltip.arrowPosition = 'bottom'
}
//
y = Math.max(offset, Math.min(y, rect.height - tooltipHeight - offset))
tooltip.x = x
tooltip.y = y
}
function hideTooltip() {
tooltip.visible = false
}
onMounted(() => {
//
})
</script>
<style scoped>
.login-trend-chart {
width: 100%;
height: 100%;
}
.chart-header {
margin-bottom: 24px;
padding: 0 4px;
}
.chart-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
}
.stat-card {
position: relative;
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.9) 0%,
rgba(255, 255, 255, 0.6) 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
backdrop-filter: blur(20px);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
var(--primary-color) 50%,
transparent 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover::before {
opacity: 1;
}
.stat-card.highlight {
background: linear-gradient(135deg,
rgba(99, 102, 241, 0.1) 0%,
rgba(139, 92, 246, 0.05) 100%);
border-color: rgba(99, 102, 241, 0.2);
}
.stat-card.highlight::before {
opacity: 0;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.stat-icon {
flex-shrink: 0;
}
.icon-wrapper {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
position: relative;
overflow: hidden;
}
.icon-wrapper::before {
content: '';
position: absolute;
inset: 0;
background: inherit;
filter: blur(8px);
opacity: 0.3;
}
.icon-wrapper svg {
position: relative;
z-index: 1;
}
.icon-wrapper.primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
}
.icon-wrapper.secondary {
background: linear-gradient(135deg, #64748b 0%, #475569 100%);
}
.icon-wrapper.success {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
.icon-wrapper.warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}
.stat-content {
flex: 1;
min-width: 0;
}
.stat-label {
font-size: 13px;
font-weight: 500;
color: var(--text-color-3);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-color-1);
margin-bottom: 2px;
line-height: 1.2;
}
.stat-value.primary {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-value.positive {
color: #10b981;
}
.stat-value.negative {
color: #ef4444;
}
.stat-subtitle {
font-size: 11px;
color: var(--text-color-3);
font-weight: 500;
}
.stat-trend {
position: absolute;
top: 12px;
right: 12px;
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.chart-container {
position: relative;
width: 100%;
overflow: visible;
background: var(--card-color);
border-radius: 12px;
padding: 24px;
border: 1px solid var(--border-color);
}
.chart-svg {
width: 100%;
height: auto;
max-width: 100%;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.05));
}
.grid-line {
stroke: rgba(148, 163, 184, 0.2);
stroke-width: 0.5;
opacity: 0.8;
}
.trend-line {
filter: drop-shadow(0 2px 4px rgba(59, 130, 246, 0.2));
}
.area-fill {
opacity: 1;
}
.data-point {
cursor: pointer;
transition: all 0.2s ease;
}
.data-point:hover {
transform: scale(1.2);
filter: drop-shadow(0 3px 8px rgba(59, 130, 246, 0.3));
}
.axis-label {
font-size: 11px;
fill: var(--text-color-2);
font-family: system-ui, -apple-system, sans-serif;
font-weight: 500;
}
/* 工具提示动画 */
.tooltip-fade-enter-active,
.tooltip-fade-leave-active {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.tooltip-fade-enter-from,
.tooltip-fade-leave-to {
opacity: 0;
transform: scale(0.8);
}
.chart-tooltip {
position: absolute;
background: var(--popover-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
z-index: 1000;
pointer-events: none;
font-size: 13px;
min-width: 120px;
backdrop-filter: blur(12px);
transform-origin: center;
}
.tooltip-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.tooltip-date {
font-weight: 600;
color: var(--text-color-1);
font-size: 14px;
margin-bottom: 2px;
}
.tooltip-value {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color-2);
}
.tooltip-text {
font-size: 13px;
}
.tooltip-text strong {
color: var(--primary-color);
font-weight: 600;
}
.tooltip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: 2px solid var(--card-color);
flex-shrink: 0;
}
/* 工具提示箭头 */
.tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
}
.tooltip-arrow.left {
right: 100%;
top: 50%;
transform: translateY(-50%);
border-right-color: var(--popover-color);
}
.tooltip-arrow.right {
left: 100%;
top: 50%;
transform: translateY(-50%);
border-left-color: var(--popover-color);
}
.tooltip-arrow.top {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-bottom-color: var(--popover-color);
}
.tooltip-arrow.bottom {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: var(--popover-color);
}
/* 响应式设计 */
@media (max-width: 1024px) {
.chart-stats {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.stat-card {
padding: 16px;
}
.icon-wrapper {
width: 40px;
height: 40px;
}
.stat-value {
font-size: 20px;
}
}
@media (max-width: 768px) {
.chart-header {
margin-bottom: 20px;
}
.chart-stats {
grid-template-columns: 1fr;
gap: 12px;
}
.stat-card {
padding: 16px;
gap: 12px;
}
.icon-wrapper {
width: 36px;
height: 36px;
border-radius: 8px;
}
.stat-value {
font-size: 18px;
}
.stat-label {
font-size: 12px;
}
.stat-subtitle {
font-size: 10px;
}
.chart-container {
padding: 16px;
border-radius: 12px;
}
.stat-trend {
width: 20px;
height: 20px;
top: 8px;
right: 8px;
}
.stat-trend svg {
width: 14px;
height: 14px;
}
}
@media (max-width: 480px) {
.chart-header {
padding: 0;
}
.stat-card {
padding: 12px;
border-radius: 12px;
}
.stat-value {
font-size: 16px;
}
.chart-container {
padding: 12px;
}
}
</style>

View File

@ -0,0 +1,698 @@
<template>
<div class="dashboard-container">
<!-- 页面头部操作区 -->
<div class="page-header mb-6">
<div class="header-left">
<h2 class="page-title">
仪表盘概览
</h2>
<p class="page-subtitle">
实时监控系统运行状态和核心数据
</p>
</div>
<!-- 实时时间显示 -->
<div class="header-center">
<div class="realtime-clock">
<div class="clock-content">
<div class="date-section">
<span class="year">{{ currentTime.year }}</span>
<span class="month-day">{{ currentTime.month }}{{ currentTime.day }}</span>
<span class="weekday">{{ currentTime.weekday }}</span>
</div>
<div class="time-section">
<span class="time">{{ currentTime.time }}</span>
</div>
</div>
</div>
</div>
<div class="header-right">
<n-button
type="primary"
:loading="loading"
@click="refreshAllData"
>
<template #icon>
<n-icon><IconParkOutline:refresh /></n-icon>
</template>
刷新数据
</n-button>
</div>
</div>
<!-- 第一行核心数据统计卡片 -->
<n-grid :x-gap="16" :y-gap="16" class="mb-4">
<n-gi :span="6">
<DashboardStatCard
title="用户统计"
:value="dashboardData.userStats.totalUsers"
subtitle="总用户数"
:extra-info="`今日新增: ${dashboardData.userStats.todayNewUsers}`"
trend="+12%"
:trend-up="true"
icon="user"
color="var(--primary-color)"
:loading="loading"
/>
</n-gi>
<n-gi :span="6">
<DashboardStatCard
title="登录统计"
:value="dashboardData.loginStats.todayLogins"
subtitle="今日登录"
:extra-info="`累计登录: ${dashboardData.loginStats.totalLogins.toLocaleString()}`"
trend="+8%"
:trend-up="true"
icon="data"
color="var(--success-color)"
:loading="loading"
/>
</n-gi>
<n-gi :span="6">
<DashboardStatCard
title="存储统计"
:value="dashboardData.storageStats.totalFiles"
subtitle="总文件数"
:extra-info="`总大小: ${dashboardData.storageStats.totalSize}`"
trend="+15%"
:trend-up="true"
icon="storage"
color="var(--warning-color)"
:loading="loading"
/>
</n-gi>
<n-gi :span="6">
<DashboardStatCard
title="今日活跃"
:value="dashboardData.dailyActivityStats.todayVisits"
subtitle="今日访问"
:extra-info="`活跃用户: ${dashboardData.dailyActivityStats.activeUsers}人`"
trend="+8%"
:trend-up="true"
icon="activity"
color="var(--info-color)"
:loading="loading"
/>
</n-gi>
</n-grid>
<!-- 第二行登录趋势分析 - 独占一行大气展示 -->
<n-grid :x-gap="20" :y-gap="20" class="mb-4">
<n-gi :span="24">
<n-card
title="登录趋势分析"
:segmented="{ content: true }"
class="enhanced-card login-trend-card full-width"
>
<template #header-extra>
<n-space>
<n-button
type="primary"
quaternary
size="small"
@click="refreshLoginTrend"
>
<template #icon>
<n-icon><IconParkOutline:refresh /></n-icon>
</template>
刷新数据
</n-button>
</n-space>
</template>
<div class="chart-container full-chart">
<LoginTrendChart
:data="dashboardData.loginStats.loginTrend"
:loading="loading"
/>
</div>
</n-card>
</n-gi>
</n-grid>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
import DashboardStatCard from './components/DashboardStatCard.vue'
import LoginTrendChart from './components/LoginTrendChart.vue'
import { getAllDashboardData, getLoginTrend } from '@/service/api/dashboard'
import type { DashboardData } from './types'
//
const currentTime = reactive({
year: '',
month: '',
day: '',
weekday: '',
time: '',
})
let timeInterval: NodeJS.Timeout | null = null
//
function updateCurrentTime() {
const now = new Date()
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
currentTime.year = now.getFullYear().toString()
currentTime.month = (now.getMonth() + 1).toString().padStart(2, '0')
currentTime.day = now.getDate().toString().padStart(2, '0')
currentTime.weekday = weekdays[now.getDay()]
currentTime.time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
}
//
function startTimeUpdate() {
updateCurrentTime() //
timeInterval = setInterval(updateCurrentTime, 1000) //
}
//
function stopTimeUpdate() {
if (timeInterval) {
clearInterval(timeInterval)
timeInterval = null
}
}
//
const dashboardData = ref<DashboardData>({
userStats: {
totalUsers: 0,
todayNewUsers: 0,
activeUsers: 0,
onlineUsers: 0,
},
loginStats: {
todayLogins: 0,
totalLogins: 0,
loginTrend: [],
},
storageStats: {
totalFiles: 0,
totalImages: 0,
totalSize: '0 MB',
todayUploads: 0,
storageUsage: 0,
availableSpace: '0 MB',
},
dailyActivityStats: {
todayVisits: 0,
todayOperations: 0,
activeUsers: 0,
newContent: 0,
apiCalls: 0,
avgResponseTime: 0,
},
})
//
const loading = ref(false)
//
async function loadDashboardData() {
loading.value = true
try {
const { isSuccess, data } = await getAllDashboardData({
includeTrend: true,
trendDays: 7,
})
if (isSuccess && data) {
dashboardData.value = {
userStats: data.userStats || dashboardData.value.userStats,
loginStats: data.loginStats || dashboardData.value.loginStats,
storageStats: data.storageStats || dashboardData.value.storageStats,
dailyActivityStats: data.dailyActivityStats || dashboardData.value.dailyActivityStats,
}
}
else {
coiMsgError('获取仪表盘数据失败,请稍后重试')
}
}
catch (error) {
coiMsgError('获取仪表盘数据失败,请检查网络连接')
console.error('加载仪表盘数据失败:', error)
}
finally {
loading.value = false
}
}
//
async function refreshLoginTrend() {
try {
const { isSuccess, data } = await getLoginTrend(7)
if (isSuccess && data && data.loginTrend) {
dashboardData.value.loginStats.loginTrend = data.loginTrend
coiMsgSuccess('登录趋势数据已刷新')
}
else {
coiMsgError('刷新登录趋势数据失败,请稍后重试')
}
}
catch (error) {
coiMsgError('刷新登录趋势数据失败')
console.error('刷新登录趋势数据失败:', error)
}
}
//
async function refreshAllData() {
await loadDashboardData()
coiMsgSuccess('仪表盘数据已刷新')
}
//
onMounted(() => {
loadDashboardData()
startTimeUpdate()
})
//
onUnmounted(() => {
stopTimeUpdate()
})
</script>
<style scoped>
.dashboard-container {
padding: 16px;
background-color: var(--body-color);
min-height: calc(100vh - 120px);
}
/* 页面头部样式 */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
padding: 0 4px;
position: relative;
}
.header-left {
flex: 1;
}
.header-center {
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 600;
color: var(--text-color-1);
line-height: 1.3;
}
.page-subtitle {
margin: 4px 0 0 0;
font-size: 14px;
color: var(--text-color-3);
line-height: 1.4;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 实时时间组件样式 */
.realtime-clock {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
border-radius: 12px;
padding: 8px 18px;
box-shadow:
0 4px 16px rgba(59, 130, 246, 0.25),
0 2px 8px rgba(59, 130, 246, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.2);
position: relative;
overflow: hidden;
min-width: 240px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.realtime-clock::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg,
rgba(255, 255, 255, 0.1) 0%,
transparent 50%,
rgba(255, 255, 255, 0.05) 100%);
pointer-events: none;
}
.realtime-clock:hover {
transform: translateY(-1px);
box-shadow:
0 6px 20px rgba(59, 130, 246, 0.3),
0 4px 12px rgba(59, 130, 246, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.clock-content {
position: relative;
z-index: 2;
color: white;
text-align: center;
}
.date-section {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-bottom: 2px;
font-size: 13px;
font-weight: 500;
opacity: 0.95;
}
.year {
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.month-day {
font-weight: 600;
color: white;
}
.weekday {
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.12);
padding: 1px 6px;
border-radius: 8px;
font-size: 11px;
}
.time-section {
font-family: 'Monaco', 'Consolas', 'Ubuntu Mono', monospace;
}
.time {
font-size: 18px;
font-weight: 700;
color: white;
letter-spacing: 1px;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
display: inline-block;
transition: all 0.3s ease;
}
/* 增强卡片样式 - 美观大气 */
.enhanced-card {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--border-color);
overflow: hidden;
}
.enhanced-card:hover {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
.login-trend-card {
background: linear-gradient(
135deg,
var(--card-color) 0%,
rgba(24, 160, 88, 0.02) 100%
);
}
/* 图表容器样式 */
.chart-container {
padding: 8px 0;
min-height: 320px;
position: relative;
}
.chart-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(
90deg,
var(--primary-color) 0%,
var(--success-color) 50%,
var(--primary-color) 100%
);
opacity: 0.3;
}
/* 全宽卡片样式增强 */
.full-width {
min-height: 400px;
}
.full-width :deep(.n-card-header) {
padding: 24px 32px 20px 32px;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 100%
);
backdrop-filter: blur(12px);
}
.full-width :deep(.n-card__content) {
padding: 28px 32px 32px 32px;
}
/* 全宽图表容器增强 */
.full-chart {
min-height: 380px;
padding: 12px 0;
}
.full-chart::before {
height: 3px;
background: linear-gradient(
90deg,
var(--primary-color) 0%,
var(--success-color) 25%,
var(--warning-color) 50%,
var(--success-color) 75%,
var(--primary-color) 100%
);
opacity: 0.4;
}
/* 卡片标题增强 */
.enhanced-card :deep(.n-card-header) {
padding: 20px 24px 16px 24px;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--divider-color);
}
.enhanced-card :deep(.n-card-header .n-card-header__main) {
font-size: 16px;
font-weight: 600;
color: var(--text-color-1);
position: relative;
}
.enhanced-card :deep(.n-card-header .n-card-header__main)::after {
content: "";
position: absolute;
bottom: -8px;
left: 0;
width: 24px;
height: 3px;
background: var(--primary-color);
border-radius: 2px;
}
/* 卡片内容区域 */
.enhanced-card :deep(.n-card__content) {
padding: 24px;
}
/* 按钮样式增强 */
.enhanced-card :deep(.n-button) {
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
}
.enhanced-card :deep(.n-button:hover) {
transform: translateY(-1px);
}
/* 响应式布局 */
@media (max-width: 1200px) {
.dashboard-container {
padding: 12px;
}
.enhanced-card :deep(.n-card-header) {
padding: 16px 20px 12px 20px;
}
.enhanced-card :deep(.n-card__content) {
padding: 20px;
}
.chart-container,
.table-container {
min-height: 280px;
}
/* 时间组件平板适配 */
.realtime-clock {
min-width: 220px;
padding: 6px 16px;
}
.date-section {
font-size: 12px;
gap: 5px;
}
.time {
font-size: 16px;
}
}
@media (max-width: 768px) {
.dashboard-container {
padding: 8px;
}
.enhanced-card {
border-radius: 8px;
}
.enhanced-card :deep(.n-card-header) {
padding: 12px 16px 8px 16px;
}
.enhanced-card :deep(.n-card__content) {
padding: 16px;
}
.chart-container,
.table-container {
min-height: 240px;
}
/* 移动端头部布局调整 */
.page-header {
flex-direction: column;
align-items: center;
gap: 16px;
padding: 0 8px;
}
.header-center {
position: static;
transform: none;
order: 2;
}
.header-left {
text-align: center;
order: 1;
}
.header-right {
order: 3;
}
/* 时间组件移动端适配 */
.realtime-clock {
min-width: 200px;
padding: 6px 14px;
border-radius: 10px;
}
.date-section {
font-size: 11px;
gap: 3px;
flex-wrap: wrap;
}
.weekday {
font-size: 10px;
padding: 1px 4px;
}
.time {
font-size: 15px;
letter-spacing: 0.5px;
}
}
@media (max-width: 480px) {
.dashboard-container {
padding: 8px;
}
.enhanced-card {
border-radius: 8px;
}
.enhanced-card :deep(.n-card-header) {
padding: 12px 16px 8px 16px;
}
.enhanced-card :deep(.n-card__content) {
padding: 16px;
}
.chart-container,
.table-container {
min-height: 240px;
}
/* 超小屏幕时间组件适配 */
.realtime-clock {
min-width: 180px;
padding: 5px 12px;
border-radius: 8px;
}
.date-section {
font-size: 10px;
gap: 2px;
}
.time {
font-size: 14px;
letter-spacing: 0.5px;
}
.page-title {
font-size: 20px;
}
.page-subtitle {
font-size: 13px;
}
}
</style>

View File

@ -0,0 +1,44 @@
// 仪表盘数据类型定义
// 从API模块重新导出类型保持一致性
export type {
DailyActivityStatsVo as DailyActivityStats,
LoginStatsVo as LoginStats,
LoginTrendItemVo,
StorageStatsVo as StorageStats,
UserStatsVo as UserStats,
} from '@/service/api/dashboard'
// 完整的仪表盘数据结构与后端API保持一致
export interface DashboardData {
userStats: UserStats
loginStats: LoginStats
storageStats: StorageStats
dailyActivityStats: DailyActivityStats
}
// 图表数据点类型
export interface ChartDataPoint {
name: string
value: number
color?: string
}
// 趋势数据类型
export interface TrendData {
label: string
value: number
date: string
}
// 统计卡片属性类型
export interface StatCardProps {
title: string
value: number | string
subtitle: string
extraInfo?: string
trend?: string
trendUp?: boolean
icon: string
color: string
}

View File

@ -0,0 +1,8 @@
<template>
<ErrorTip type="403" />
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,8 @@
<template>
<ErrorTip type="404" />
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,8 @@
<template>
<ErrorTip type="500" />
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,481 @@
<template>
<div class="space-y-6">
<!-- 重定向Loading状态 -->
<div v-if="isRedirectLoading" class="text-center space-y-6 py-8">
<div class="flex justify-center">
<n-spin size="large" stroke="var(--primary-color)" />
</div>
<div class="space-y-2">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
正在检查登录状态...
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">
请稍候系统正在为您准备登录界面
</p>
</div>
<div class="flex justify-center space-x-1">
<div class="loading-dot" style="animation-delay: 0s;" />
<div class="loading-dot" style="animation-delay: 0.1s;" />
<div class="loading-dot" style="animation-delay: 0.2s;" />
</div>
</div>
<!-- 正常登录状态 -->
<div v-else>
<!-- 头部问候语 -->
<div class="text-center mb-10">
<!-- 装饰性顶部图标 -->
<div class="flex justify-center mb-6">
<div class="relative">
<div
class="w-16 h-16 rounded-full flex items-center justify-center shadow-lg animate-pulse"
:style="{
background: `linear-gradient(135deg, ${primaryColor}, ${primaryColorPressed})`,
}"
>
<div class="text-white text-2xl">
🔐
</div>
</div>
<!-- 装饰性光环 -->
<div
class="absolute inset-0 rounded-full animate-ping"
:style="{
background: `linear-gradient(135deg, ${primaryColor}20, ${primaryColorPressed}20)`,
}"
/>
</div>
</div>
<!-- 主标题 -->
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-4 leading-tight">
欢迎回来 👋
</h1>
<!-- 描述文字 -->
<p class="text-base text-gray-600 dark:text-gray-400 leading-relaxed max-w-sm mx-auto">
请输入您的详细信息以开始管理您的帐户
</p>
<!-- 装饰性分隔线 -->
<div class="flex items-center justify-center mt-6">
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 to-transparent dark:via-gray-600" />
<div
class="mx-4 w-2 h-2 rounded-full"
:style="{ backgroundColor: primaryColor }"
/>
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-gray-200 to-transparent dark:via-gray-600" />
</div>
</div>
<!-- 登录表单 -->
<n-form ref="formRef" :rules="rules" :model="formValue" :show-label="false" size="large" class="space-y-5" @keyup.enter="handleLogin">
<!-- 账号输入 -->
<n-form-item path="account">
<n-input
v-model:value="formValue.account"
clearable
:placeholder="$t('login.accountPlaceholder')"
class="login-input"
@keyup.enter="handleLogin"
/>
</n-form-item>
<!-- 密码输入 -->
<n-form-item path="pwd">
<n-input
v-model:value="formValue.pwd"
type="password"
:placeholder="$t('login.passwordPlaceholder')"
clearable
show-password-on="click"
class="login-input"
@keyup.enter="handleLogin"
>
<template #password-invisible-icon>
<icon-park-outline-preview-close-one />
</template>
<template #password-visible-icon>
<icon-park-outline-preview-open />
</template>
</n-input>
</n-form-item>
<!-- 验证码输入 -->
<n-form-item path="securityCode">
<div class="flex w-full gap-3">
<n-input
v-model:value="formValue.securityCode"
placeholder="请输入验证码"
clearable
class="flex-1 login-input"
maxlength="5"
@keyup.enter="handleLogin"
/>
<div
class="w-32 h-12 cursor-pointer rounded-lg border-2 border-gray-200 flex items-center justify-center overflow-hidden bg-white dark:bg-gray-700 dark:border-gray-600 transition-colors duration-200 captcha-button"
title="点击刷新验证码"
@click="getCaptcha"
>
<img
v-if="captchaImage"
:src="captchaImage"
alt="验证码"
class="w-full h-full object-contain"
>
<span v-else class="text-sm text-gray-500 dark:text-gray-400">点击获取验证码</span>
</div>
</div>
</n-form-item>
<!-- 记住密码和忘记密码 -->
<div class="flex items-center justify-between py-2">
<n-checkbox v-model:checked="isRemember" class="text-sm">
{{ $t('login.rememberMe') }}
</n-checkbox>
<n-button type="primary" text class="text-sm hover:underline" @click="toOtherForm('resetPwd')">
{{ $t('login.forgotPassword') }}
</n-button>
</div>
<!-- 登录按钮 -->
<div class="pt-4">
<n-button
block
type="primary"
size="large"
:loading="isLoading"
:disabled="isLoading"
class="login-button"
@click="handleLogin"
>
{{ $t('login.signIn') }}
</n-button>
</div>
<!-- 注册链接 -->
<!-- <div class="text-center"> -->
<!-- <span class="text-sm text-gray-600 dark:text-gray-400">{{ $t('login.noAccountText') }}</span> -->
<!-- <n-button type="primary" text @click="toOtherForm('register')" class="text-sm ml-1"> -->
<!-- {{ $t('login.signUp') }} -->
<!-- </n-button> -->
<!-- </div> -->
</n-form>
</div>
</div>
</template>
<script setup lang="ts">
import type { FormInst } from 'naive-ui'
import { useAppStore, useAuthStore } from '@/store'
import { fetchCaptchaPng } from '@/service/api/auth'
import { local } from '@/utils'
import { coiMsgWarning } from '@/utils/coi'
const emit = defineEmits(['update:modelValue'])
const authStore = useAuthStore()
const appStore = useAppStore()
//
const primaryColor = computed(() => appStore.primaryColor)
const primaryColorHover = computed(() => appStore.theme.common.primaryColorHover)
const primaryColorPressed = computed(() => appStore.theme.common.primaryColorPressed)
function toOtherForm(type: any) {
emit('update:modelValue', type)
}
const { t } = useI18n()
const rules = computed(() => {
return {
account: {
required: true,
trigger: 'blur',
message: t('login.accountRuleTip'),
},
pwd: {
required: true,
trigger: 'blur',
message: t('login.passwordRuleTip'),
},
securityCode: {
required: true,
trigger: 'blur',
message: '请输入验证码',
},
}
})
const formValue = ref({
account: 'yuadmin',
pwd: 'yuadmin123',
securityCode: '',
})
const isRemember = ref(false)
const isLoading = ref(false)
const isRedirectLoading = ref(false)
//
const captchaImage = ref('')
const captchaKey = ref('')
//
async function getCaptcha() {
try {
const { isSuccess, data } = await fetchCaptchaPng()
if (isSuccess) {
captchaImage.value = data.captchaPicture
captchaKey.value = data.codeKey
}
}
catch (e) {
console.warn('[获取验证码失败]:', e)
}
}
const formRef = ref<FormInst | null>(null)
function handleLogin() {
//
if (isLoading.value)
return
formRef.value?.validate(async (errors) => {
if (errors)
return
isLoading.value = true
const { account, pwd, securityCode } = formValue.value
if (isRemember.value)
local.set('loginAccount', { account, pwd })
else local.remove('loginAccount')
try {
await authStore.login(account, pwd, captchaKey.value, securityCode, isRemember.value)
}
catch (error) {
//
await getCaptcha()
formValue.value.securityCode = ''
console.warn('[Login Failed]:', error)
}
finally {
isLoading.value = false
}
})
}
onMounted(() => {
checkUserAccount()
getCaptcha()
// loading
const route = useRoute()
if (route.query.redirect) {
isRedirectLoading.value = true
// loading
setTimeout(() => {
coiMsgWarning('账号身份过期,请你重新登录')
// loading
setTimeout(() => {
isRedirectLoading.value = false
}, 200)
}, 1000)
}
})
function checkUserAccount() {
const loginAccount = local.get('loginAccount')
if (!loginAccount)
return
formValue.value = { ...formValue.value, ...loginAccount }
isRemember.value = true
}
</script>
<style scoped>
.login-input {
--n-border-radius: 8px;
--n-border: 1px solid #e5e7eb;
--n-border-hover: 1px solid v-bind(primaryColor);
--n-border-focus: 1px solid v-bind(primaryColor);
--n-box-shadow-focus: 0 0 0 3px v-bind(`${primaryColor}19`);
--n-padding-left: 16px;
--n-padding-right: 16px;
--n-font-size: 14px;
--n-height: 48px;
}
.login-button {
--n-border-radius: 8px;
--n-height: 48px;
--n-font-size: 16px;
--n-font-weight: 600;
background: linear-gradient(135deg, v-bind(primaryColor) 0%, v-bind(primaryColorPressed) 100%);
border: none;
transition: all 0.2s ease;
}
.login-button:hover {
background: linear-gradient(135deg, v-bind(primaryColorHover) 0%, v-bind(primaryColorPressed) 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px v-bind(`${primaryColor}4D`);
}
.social-login-btn {
@apply w-12 h-12 rounded-full border-2 border-gray-200 dark:border-gray-600 flex items-center justify-center transition-all duration-200 text-gray-600 dark:text-gray-400;
&:hover {
border-color: v-bind(primaryColor);
background: v-bind(`${primaryColor}0D`);
color: v-bind(primaryColor);
}
}
.social-login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.captcha-button:hover {
border-color: v-bind(primaryColor);
}
/* 暗色主题适配 */
.dark .login-input {
--n-color: #374151;
--n-border: 1px solid #4b5563;
--n-border-hover: 1px solid v-bind(primaryColor);
--n-color-focus: #374151;
--n-text-color: #f3f4f6;
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* Loading点动画 */
.loading-dot {
width: 8px;
height: 8px;
background: v-bind(primaryColor);
border-radius: 50%;
animation: bounce 1.4s ease-in-out infinite both;
}
@keyframes bounce {
0%, 80%, 100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
/* 表单容器优化 */
.n-form {
transition: all 0.3s ease;
}
.n-form-item {
transition: all 0.2s ease;
}
.n-form-item:hover {
transform: translateY(-1px);
}
/* 验证码区域优化 */
.captcha-button {
transition: all 0.2s ease;
}
.captcha-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 装饰性图标动画 */
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-5px);
}
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* 光环动画优化 */
@keyframes ping-slow {
0% {
transform: scale(1);
opacity: 1;
}
75%, 100% {
transform: scale(1.5);
opacity: 0;
}
}
.animate-ping {
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* 分隔线动画 */
.h-px {
transition: all 0.3s ease;
}
/* 记住密码区域优化 */
.py-2 {
transition: all 0.2s ease;
}
/* 按钮容器优化 */
.pt-4 {
transition: all 0.3s ease;
}
/* 响应式设计 */
@media (max-width: 640px) {
.login-input {
--n-height: 44px;
}
.login-button {
--n-height: 44px;
--n-font-size: 14px;
}
/* 移动端优化头部区域 */
.text-3xl {
font-size: 1.75rem;
}
.w-16.h-16 {
width: 3.5rem;
height: 3.5rem;
}
.mb-10 {
margin-bottom: 2rem;
}
}
</style>

View File

@ -0,0 +1,123 @@
<template>
<div>
<n-h2 depth="3" class="text-center">
{{ $t('login.registerTitle') }}
</n-h2>
<n-form
:rules="rules"
:model="formValue"
:show-label="false"
size="large"
>
<n-form-item path="account">
<n-input
v-model:value="formValue.account"
clearable
:placeholder="$t('login.accountPlaceholder')"
/>
</n-form-item>
<n-form-item path="pwd">
<n-input
v-model:value="formValue.pwd"
type="password"
:placeholder="$t('login.passwordPlaceholder')"
clearable
show-password-on="click"
>
<template #password-invisible-icon>
<icon-park-outline-preview-close-one />
</template>
<template #password-visible-icon>
<icon-park-outline-preview-open />
</template>
</n-input>
</n-form-item>
<n-form-item path="rePwd">
<n-input
v-model:value="formValue.rePwd"
type="password"
:placeholder="$t('login.checkPasswordPlaceholder')"
clearable
show-password-on="click"
>
<template #password-invisible-icon>
<icon-park-outline-preview-close-one />
</template>
<template #password-visible-icon>
<icon-park-outline-preview-open />
</template>
</n-input>
</n-form-item>
<n-form-item>
<n-space
vertical
:size="20"
class="w-full"
>
<n-checkbox v-model:checked="isRead">
{{ $t('login.readAndAgree') }} <n-button
type="primary"
text
>
{{ $t('login.userAgreement') }}
</n-button>
</n-checkbox>
<n-button
block
type="primary"
@click="handleRegister"
>
{{ $t('login.signUp') }}
</n-button>
<n-flex justify="center">
<n-text>{{ $t('login.haveAccountText') }}</n-text>
<n-button
text
type="primary"
@click="toLogin"
>
{{ $t('login.signIn') }}
</n-button>
</n-flex>
</n-space>
</n-form-item>
</n-form>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits(['update:modelValue'])
function toLogin() {
emit('update:modelValue', 'login')
}
const { t } = useI18n()
const rules = {
account: {
required: true,
trigger: 'blur',
message: t('login.accountRuleTip'),
},
pwd: {
required: true,
trigger: 'blur',
message: t('login.passwordRuleTip'),
},
rePwd: {
required: true,
trigger: 'blur',
message: t('login.checkPasswordRuleTip'),
},
}
const formValue = ref({
account: 'yuadmin',
pwd: '000000',
rePwd: '000000',
})
const isRead = ref(false)
function handleRegister() {}
</script>
<style scoped></style>

View File

@ -0,0 +1,76 @@
<template>
<div>
<n-h2 depth="3" class="text-center">
{{ $t('login.resetPasswordTitle') }}
</n-h2>
<n-form
ref="formRef"
:rules="rules"
:model="formValue"
:show-label="false"
size="large"
>
<n-form-item path="account">
<n-input
v-model:value="formValue.account"
clearable
:placeholder="$t('login.resetPasswordPlaceholder')"
/>
</n-form-item>
<n-form-item>
<n-space
vertical
:size="20"
class="w-full"
>
<n-button
block
type="primary"
@click="handleRegister"
>
{{ $t('login.resetPassword') }}
</n-button>
<n-flex justify="center">
<n-text>{{ $t('login.haveAccountText') }}</n-text>
<n-button
text
type="primary"
@click="toLogin"
>
{{ $t('login.signIn') }}
</n-button>
</n-flex>
</n-space>
</n-form-item>
</n-form>
</div>
</template>
<script setup lang="ts">
import type { FormInst } from 'naive-ui'
const emit = defineEmits(['update:modelValue'])
function toLogin() {
emit('update:modelValue', 'login')
}
const { t } = useI18n()
const rules = computed(() => {
return {
account: {
required: true,
trigger: 'blur',
message: t('login.resetPasswordRuleTip'),
},
}
})
const formValue = ref({
account: '',
})
const formRef = ref<FormInst | null>(null)
function handleRegister() {
formRef.value?.validate()
}
</script>
<style scoped></style>

View File

@ -0,0 +1,5 @@
import Login from './Login/index.vue'
import Register from './Register/index.vue'
import ResetPwd from './ResetPwd/index.vue'
export { Login, Register, ResetPwd }

191
src/views/login/index.vue Normal file
View File

@ -0,0 +1,191 @@
<template>
<div class="h-screen w-screen overflow-hidden">
<!-- 全局设置按钮 -->
<div class="fixed top-6 right-6 z-50 text-lg">
<DarkModeSwitch />
<LangsSwitch />
</div>
<div class="h-full w-full flex">
<!-- 左侧介绍区域 -->
<div
class="hidden lg:flex lg:w-1/2 xl:w-3/5 relative"
:style="{
background: `linear-gradient(135deg, ${primaryColor}, ${primaryColorPressed})`,
}"
>
<div
class="absolute inset-0"
:style="{
background: `linear-gradient(135deg, ${primaryColor}33, ${primaryColorHover}33)`,
}"
/>
<!-- 装饰性图案 -->
<div class="absolute top-20 left-20 w-32 h-32 bg-white/10 rounded-full blur-xl" />
<div class="absolute bottom-40 right-20 w-24 h-24 bg-white/10 rounded-full blur-lg" />
<div class="absolute top-1/2 left-10 w-16 h-16 bg-white/10 rounded-full blur-md" />
<div class="relative z-10 flex flex-col justify-center items-center w-full px-16 text-white">
<!-- 3D等距图标区域 -->
<div class="mb-12">
<div class="relative w-80 h-80 flex items-center justify-center">
<!-- 主要3D平台 -->
<div class="relative">
<!-- 平台基座 -->
<div
class="w-64 h-20 rounded-lg transform perspective-1000 rotate-x-60 shadow-2xl relative overflow-hidden"
:style="{
background: `linear-gradient(90deg, ${primaryColorHover}, ${primaryColor})`,
}"
>
<!-- 平台表面纹理 -->
<div
class="absolute inset-0"
:style="{
background: `linear-gradient(90deg, ${primaryColorHover}4D, ${primaryColorPressed}4D)`,
}"
/>
<!-- 网格纹理 -->
<div class="absolute inset-0 opacity-20" style="background-image: linear-gradient(45deg, transparent 25%, rgba(255,255,255,0.1) 25%, rgba(255,255,255,0.1) 50%, transparent 50%, transparent 75%, rgba(255,255,255,0.1) 75%); background-size: 20px 20px;" />
</div>
<!-- 中央圆形平台 -->
<div
class="absolute top-[-40px] left-1/2 transform -translate-x-1/2 w-32 h-32 rounded-full shadow-xl border-4"
:style="{
background: `linear-gradient(180deg, ${primaryColor}, ${primaryColorHover})`,
borderColor: `${primaryColorHover}80`,
}"
>
<!-- 内圈发光效果 -->
<div
class="absolute inset-2 rounded-full"
:style="{
background: `linear-gradient(180deg, ${primaryColorHover}, ${primaryColorPressed})`,
}"
/>
<div
class="absolute inset-4 rounded-full animate-pulse"
:style="{
background: `linear-gradient(180deg, ${primaryColorHover}, rgba(255,255,255,0.9))`,
}"
/>
</div>
<!-- 左侧旗帜 -->
<div
class="absolute top-[-80px] left-8 w-2 h-20 rounded-full shadow-lg"
:style="{
background: `linear-gradient(180deg, ${primaryColorPressed}, ${primaryColorHover})`,
}"
>
<div
class="absolute top-0 left-2 w-12 h-8 rounded-r transform -skew-y-12 shadow-md"
:style="{
background: `linear-gradient(90deg, ${primaryColorHover}, ${primaryColor})`,
}"
/>
</div>
<!-- 右侧旗帜 -->
<div
class="absolute top-[-70px] right-12 w-2 h-16 rounded-full shadow-lg"
:style="{
background: `linear-gradient(180deg, ${primaryColorPressed}, ${primaryColorHover})`,
}"
>
<div
class="absolute top-0 right-2 w-10 h-6 rounded-l transform skew-y-12 shadow-md"
:style="{
background: `linear-gradient(90deg, ${primaryColor}, ${primaryColorHover})`,
}"
/>
</div>
<!-- 装饰性云朵 -->
<div class="absolute top-[-60px] left-[-20px] w-8 h-8 bg-white/30 rounded-full" />
<div class="absolute top-[-50px] left-[-15px] w-6 h-6 bg-white/20 rounded-full" />
<div class="absolute top-[-70px] right-[-10px] w-6 h-6 bg-white/25 rounded-full" />
</div>
<!-- 浮动粒子效果 -->
<div class="absolute top-10 left-10 w-2 h-2 bg-white/60 rounded-full animate-bounce" style="animation-delay: 0s; animation-duration: 2s;" />
<div class="absolute top-20 right-16 w-1.5 h-1.5 bg-white/40 rounded-full animate-bounce" style="animation-delay: 0.5s; animation-duration: 2.5s;" />
<div class="absolute bottom-20 left-20 w-1 h-1 bg-white/50 rounded-full animate-bounce" style="animation-delay: 1s; animation-duration: 3s;" />
<div class="absolute bottom-16 right-8 w-2 h-2 bg-white/30 rounded-full animate-bounce" style="animation-delay: 1.5s; animation-duration: 2s;" />
</div>
</div>
<!-- 文字内容 -->
<div class="text-center">
<h1 class="text-4xl font-bold mb-4 leading-tight">
Coi Admin 全栈管理平台
</h1>
<p class="text-xl text-blue-100 max-w-md leading-relaxed">
现代化技术栈 · 企业级架构 · 开箱即用
</p>
</div>
</div>
</div>
<!-- 右侧登录区域 -->
<div class="w-full lg:w-1/2 xl:w-2/5 bg-white dark:bg-gray-900 flex items-center justify-center px-8 py-12">
<div class="w-full max-w-md">
<transition name="fade-slide" mode="out-in">
<component
:is="formComponets[formType]"
v-model="formType"
class="w-full"
/>
</transition>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Login, Register, ResetPwd } from './components'
import { useAppStore } from '@/store'
type IformType = 'login' | 'register' | 'resetPwd'
const formType: Ref<IformType> = ref('login')
const formComponets = {
login: Login,
register: Register,
resetPwd: ResetPwd,
}
const appStore = useAppStore()
//
const primaryColor = computed(() => appStore.primaryColor)
const primaryColorHover = computed(() => appStore.theme.common.primaryColorHover)
const primaryColorPressed = computed(() => appStore.theme.common.primaryColorPressed)
</script>
<style scoped>
.perspective-1000 {
perspective: 1000px;
}
.rotate-x-60 {
transform: rotateX(60deg);
}
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>