功能模块:添加布局、登录和仪表盘
- 添加页面布局组件(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:
parent
cb98681927
commit
e8a78fa8b6
1
src/assets/svg-icons/cool.svg
Normal file
1
src/assets/svg-icons/cool.svg
Normal 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 |
1
src/assets/svg-icons/logo.svg
Normal file
1
src/assets/svg-icons/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/svg/error-403.svg
Normal file
1
src/assets/svg/error-403.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 60 KiB |
1
src/assets/svg/error-404.svg
Normal file
1
src/assets/svg/error-404.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 71 KiB |
1
src/assets/svg/error-500.svg
Normal file
1
src/assets/svg/error-500.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 41 KiB |
15
src/layouts/components/common/BackTop.vue
Normal file
15
src/layouts/components/common/BackTop.vue
Normal 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>
|
||||
72
src/layouts/components/common/LayoutSelector.vue
Normal file
72
src/layouts/components/common/LayoutSelector.vue
Normal 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>
|
||||
45
src/layouts/components/common/NoticeList.vue
Normal file
45
src/layouts/components/common/NoticeList.vue
Normal 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>
|
||||
18
src/layouts/components/common/Setting.vue
Normal file
18
src/layouts/components/common/Setting.vue
Normal 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>
|
||||
148
src/layouts/components/common/SettingDrawer.vue
Normal file
148
src/layouts/components/common/SettingDrawer.vue
Normal 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>
|
||||
50
src/layouts/components/header/Breadcrumb.vue
Normal file
50
src/layouts/components/header/Breadcrumb.vue
Normal 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>
|
||||
19
src/layouts/components/header/CollapaseButton.vue
Normal file
19
src/layouts/components/header/CollapaseButton.vue
Normal 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>
|
||||
27
src/layouts/components/header/FullScreen.vue
Normal file
27
src/layouts/components/header/FullScreen.vue
Normal 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>
|
||||
144
src/layouts/components/header/Notices.vue
Normal file
144
src/layouts/components/header/Notices.vue
Normal 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>
|
||||
217
src/layouts/components/header/Search.vue
Normal file
217
src/layouts/components/header/Search.vue
Normal 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
|
||||
// 单个元素的高度,包括了元素的gap和容器的padding
|
||||
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>
|
||||
26
src/layouts/components/index.ts
Normal file
26
src/layouts/components/index.ts
Normal 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,
|
||||
}
|
||||
83
src/layouts/components/sider/Logo.vue
Normal file
83
src/layouts/components/sider/Logo.vue
Normal 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>
|
||||
29
src/layouts/components/sider/Menu.vue
Normal file
29
src/layouts/components/sider/Menu.vue
Normal 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>
|
||||
17
src/layouts/components/tab/ContentFullScreen.vue
Normal file
17
src/layouts/components/tab/ContentFullScreen.vue
Normal 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>
|
||||
41
src/layouts/components/tab/DropTabs.vue
Normal file
41
src/layouts/components/tab/DropTabs.vue
Normal 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>
|
||||
28
src/layouts/components/tab/Reload.vue
Normal file
28
src/layouts/components/tab/Reload.vue
Normal 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>
|
||||
147
src/layouts/components/tab/TabBar.vue
Normal file
147
src/layouts/components/tab/TabBar.vue
Normal 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>
|
||||
41
src/layouts/components/tab/TabBarItem.vue
Normal file
41
src/layouts/components/tab/TabBarItem.vue
Normal 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
19
src/layouts/index.vue
Normal 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>
|
||||
99
src/layouts/leftMenu.layout.vue
Normal file
99
src/layouts/leftMenu.layout.vue
Normal 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>
|
||||
157
src/layouts/mixMenu.layout.vue
Normal file
157
src/layouts/mixMenu.layout.vue
Normal 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>
|
||||
64
src/layouts/topMenu.layout.vue
Normal file
64
src/layouts/topMenu.layout.vue
Normal 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
6
src/modules/assets.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import 'uno.css'
|
||||
import '@/styles/index.css'
|
||||
|
||||
// 全局引入的静态资源
|
||||
export function install() {
|
||||
}
|
||||
10
src/modules/directives.ts
Normal file
10
src/modules/directives.ts
Normal 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
26
src/modules/i18n.ts
Normal 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
20
src/styles/index.css
Normal 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
14
src/styles/naive.css
Normal 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
403
src/styles/reset.css
Normal 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
92
src/styles/transition.css
Normal 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);
|
||||
}
|
||||
283
src/views/dashboard/components/DashboardStatCard.vue
Normal file
283
src/views/dashboard/components/DashboardStatCard.vue
Normal 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>
|
||||
906
src/views/dashboard/components/LoginTrendChart.vue
Normal file
906
src/views/dashboard/components/LoginTrendChart.vue
Normal 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>
|
||||
698
src/views/dashboard/index.vue
Normal file
698
src/views/dashboard/index.vue
Normal 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>
|
||||
44
src/views/dashboard/types.ts
Normal file
44
src/views/dashboard/types.ts
Normal 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
|
||||
}
|
||||
8
src/views/error/403/index.vue
Normal file
8
src/views/error/403/index.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ErrorTip type="403" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
8
src/views/error/404/index.vue
Normal file
8
src/views/error/404/index.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ErrorTip type="404" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
8
src/views/error/500/index.vue
Normal file
8
src/views/error/500/index.vue
Normal file
@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<ErrorTip type="500" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
481
src/views/login/components/Login/index.vue
Normal file
481
src/views/login/components/Login/index.vue
Normal 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>
|
||||
123
src/views/login/components/Register/index.vue
Normal file
123
src/views/login/components/Register/index.vue
Normal 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>
|
||||
76
src/views/login/components/ResetPwd/index.vue
Normal file
76
src/views/login/components/ResetPwd/index.vue
Normal 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>
|
||||
5
src/views/login/components/index.ts
Normal file
5
src/views/login/components/index.ts
Normal 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
191
src/views/login/index.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user