feat(dashboard): 欢迎页对齐原生 GERP 并移除技术栈展示

- 欢迎区:logo + 标题 + 欢迎语 + 每日一言(6s 轮播/点击切换/300ms 淡入淡出)+ 个人中心/快捷操作
- 欢迎区改为占满剩余空间垂直居中(对齐原生 welcome-container),去除面板边框
- 删除「系统信息」面板(含前端/表格/后端技术栈描述行)
- 快捷入口超过 10 项折叠为「展开全部(N)/ 收起」,避免菜单过多拉长页面
This commit is contained in:
TermiNexus committed 2026-09-26 23:46:41 +08:00
1 parent 1c0885126d
commit e79e3bb28b
1 file changed
+207 -26
+207 -26
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { get } from '@/api/request'
import { useUserStore } from '@/stores/user'
@@ -12,6 +12,33 @@ interface DashboardStats {
ActiveTenants: number
}
/** 欢迎页 logo(与顶部导航同源 CDN;原生欢迎页用的是 /assets/img/CenterICON.png) */
const logoUrl = 'https://hb.cdn.gvsds.com/assets/img/GERP/GERP.png'
/** 每日一言池(内容沿用原生 GERP 欢迎页 pages/dashboard.html 的 quotes) */
const quotes: ReadonlyArray<{ text: string; author: string }> = [
{ text: '代码是写给人看的,顺便给机器运行。', author: 'SICP' },
{ text: '过早优化是万恶之源。', author: 'Donald Knuth' },
{ text: '简单是可靠的前提。', author: 'Dijkstra' },
{ text: '不要重复自己。', author: 'DRY 原则' },
{ text: '如果你不能简单地解释它,说明你理解得还不够。', author: '爱因斯坦' },
{ text: '好代码本身就是最好的文档。', author: 'Steve McConnell' },
{ text: '调试比写代码难一倍。', author: 'Brian Kernighan' },
{ text: '先让代码能跑,再优化。', author: 'Kent Beck' },
{ text: '命名是编程中最难的事情之一。', author: 'Phil Karlton' },
{ text: '测试驱动开发不仅仅是一种测试方法。', author: 'Martin Fowler' },
{ text: '设计是让东西能用的艺术。', author: 'Steve Jobs' },
{ text: '少即是多。', author: 'Mies van der Rohe' },
{ text: '任何傻瓜都能写出计算机能理解的代码。', author: 'Martin Fowler' },
{ text: '形式追随功能。', author: 'Louis Sullivan' },
{ text: '细节决定成败。', author: 'Charles Eames' },
{ text: '好设计就是尽可能少的设计。', author: 'Dieter Rams' },
{ text: '修改代码不改变行为,这就是重构。', author: 'Martin Fowler' },
{ text: '遗留代码就是没有测试的代码。', author: 'Michael Feathers' },
{ text: '软件不是写出来的,是改出来的。', author: '佚名' },
{ text: '面对接口编程,不是面对实现。', author: 'GoF 设计模式' },
]
const router = useRouter()
const userStore = useUserStore()
@@ -23,7 +50,43 @@ const stats = ref<DashboardStats>({
})
const loading = ref(false)
// === 每日一言:6s 自动轮播,点击手动切换并重置计时 ===
/** 当前名言下标 */
const quoteIndex = ref(0)
/** 名言透明度状态:切换时先淡出、换文案后淡入(对齐原生 300ms 节奏) */
const quoteVisible = ref(true)
/** 当前展示的名言(下标越界兜底取首条,避免取值为 undefined) */
const currentQuote = computed(() => quotes[quoteIndex.value] ?? quotes[0])
let quoteTimer: ReturnType<typeof setInterval> | null = null
let quoteSwapTimer: ReturnType<typeof setTimeout> | null = null
/** 切到下一条名言(淡出 → 换文案 → 淡入) */
function nextQuote(): void {
quoteVisible.value = false
if (quoteSwapTimer) clearTimeout(quoteSwapTimer)
quoteSwapTimer = setTimeout(() => {
quoteIndex.value = (quoteIndex.value + 1) % quotes.length
quoteVisible.value = true
}, 300)
}
/** 点击名言:切换后重新计时,避免刚点完又被自动轮播立刻跳走 */
function handleQuoteClick(): void {
nextQuote()
if (quoteTimer) clearInterval(quoteTimer)
quoteTimer = setInterval(nextQuote, 6000)
}
// === 快捷操作按钮:滚动到「快捷入口」面板 ===
const quickSectionRef = ref<HTMLElement | null>(null)
function scrollToQuick(): void {
quickSectionRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
onMounted(async () => {
quoteTimer = setInterval(nextQuote, 6000)
loading.value = true
try {
const data = await get<DashboardStats>('/API/Dashboard/Stats')
@@ -33,6 +96,11 @@ onMounted(async () => {
}
})
onBeforeUnmount(() => {
if (quoteTimer) clearInterval(quoteTimer)
if (quoteSwapTimer) clearTimeout(quoteSwapTimer)
})
/** 关键指标(企业控制台 KPI 数值区) */
const statItems = computed(() => [
{ label: '用户总数', value: String(stats.value.UserCount ?? 0) },
@@ -44,6 +112,17 @@ const statItems = computed(() => [
/** 快捷入口:取菜单一级项;含子菜单时跳首个子页 */
const quickMenus = computed<MenuItem[]>(() => userStore.menus || [])
/** 快捷入口折叠阈值:超出后只展示前 N 个,其余由「展开全部」收起(避免菜单多时页面被拉长) */
const QUICK_LIMIT = 10
/** 快捷入口是否展开全部 */
const quickExpanded = ref(false)
/** 当前实际展示的快捷入口 */
const visibleQuickMenus = computed<MenuItem[]>(() =>
quickExpanded.value ? quickMenus.value : quickMenus.value.slice(0, QUICK_LIMIT),
)
/** 是否存在被折叠的快捷入口 */
const quickOverflow = computed(() => quickMenus.value.length > QUICK_LIMIT)
/** 判断图标是否为 FontAwesome 类名(与布局菜单一致) */
const isFaIcon = (icon: string | undefined | null): boolean => {
if (!icon) return false
@@ -56,16 +135,33 @@ function goMenu(menu: MenuItem): void {
const target = menu.Path || menu.Children?.[0]?.Path
if (target) router.push(target)
}
/** 当前角色文案 */
const roleLabel = computed(() => {
if (userStore.isAdmin) return '管理员'
return userStore.currentRole || '普通用户'
})
</script>
<template>
<div v-loading="loading" class="dashboard">
<!-- 欢迎区(对齐原生 GERP 欢迎页:logo + 标题 + 每日一言 + 快捷操作) -->
<section class="welcome-panel">
<img class="welcome-logo" :src="logoUrl" alt="GERP" />
<h1 class="welcome-title">GERP 管理系统</h1>
<p class="welcome-subtitle">{{ userStore.displayName }},欢迎回来</p>
<div class="welcome-quote" title="点击换一条" @click="handleQuoteClick">
<div class="quote-text" :class="quoteVisible ? 'quote-fade-in' : 'quote-fade-out'">
“{{ currentQuote.text }}”
</div>
<div class="quote-author" :class="quoteVisible ? 'quote-fade-in' : 'quote-fade-out'">
— {{ currentQuote.author }}
</div>
</div>
<div class="welcome-actions">
<el-button type="primary" @click="router.push('/profile')">
<el-icon><User /></el-icon>个人中心
</el-button>
<el-button @click="scrollToQuick">
<el-icon><Grid /></el-icon>快捷操作
</el-button>
</div>
</section>
<!-- 关键指标 -->
<section class="gerp-panel stat-panel">
<div class="gerp-panel-header">
@@ -81,14 +177,14 @@ const roleLabel = computed(() => {
</section>
<!-- 快捷入口 -->
<section v-if="quickMenus.length" class="gerp-panel">
<section v-if="quickMenus.length" ref="quickSectionRef" class="gerp-panel">
<div class="gerp-panel-header">
<el-icon><Grid /></el-icon>
<span>快捷入口</span>
</div>
<div class="quick-grid">
<div
v-for="menu in quickMenus"
v-for="menu in visibleQuickMenus"
:key="menu.Path || menu.Title"
class="quick-item"
@click="goMenu(menu)"
@@ -97,24 +193,13 @@ const roleLabel = computed(() => {
<el-icon v-else-if="menu.Icon" class="quick-item-icon"><component :is="menu.Icon" /></el-icon>
<span class="quick-item-label">{{ menu.Title }}</span>
</div>
<!-- 折叠开关:仅在菜单超出阈值时出现,展开/收起不改变面板宽度 -->
<div v-if="quickOverflow" class="quick-item quick-item-toggle" @click="quickExpanded = !quickExpanded">
<el-icon class="quick-item-icon"><component :is="quickExpanded ? 'ArrowUp' : 'MoreFilled'" /></el-icon>
<span class="quick-item-label">{{ quickExpanded ? '收起' : `展开全部(${quickMenus.length})` }}</span>
</div>
</div>
</section>
<!-- 系统信息 -->
<section class="gerp-panel">
<div class="gerp-panel-header">
<el-icon><Monitor /></el-icon>
<span>系统信息</span>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="当前用户">{{ userStore.displayName }}</el-descriptions-item>
<el-descriptions-item label="当前角色">{{ roleLabel }}</el-descriptions-item>
<el-descriptions-item label="前端技术栈">Vue 3 + TypeScript + Element Plus</el-descriptions-item>
<el-descriptions-item label="数据表格">vxe-table · FormKit 表单</el-descriptions-item>
<el-descriptions-item label="后端服务">Django + DRF</el-descriptions-item>
<el-descriptions-item label="系统版本">v1.0.0</el-descriptions-item>
</el-descriptions>
</section>
</div>
</template>
@@ -129,6 +214,79 @@ const roleLabel = computed(() => {
overflow: auto;
}
/* 欢迎区:不带面板边框;占用剩余空间并垂直居中(原生欢迎页即整屏居中,而非贴顶) */
.welcome-panel {
flex: 1 1 auto;
/* 保底高度:面板内容较高时仍保证欢迎区有足够留白,不被压扁 */
min-height: 320px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 24px 20px;
}
.welcome-logo {
width: 80px;
height: 80px;
object-fit: contain;
}
.welcome-title {
margin: 12px 0 0;
font-size: 28px;
font-weight: 700;
line-height: 1.3;
color: var(--el-text-color-primary);
}
.welcome-subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--el-text-color-secondary);
}
/* 每日一言:可点击换一条,切换走透明度过渡 */
.welcome-quote {
margin-top: 18px;
max-width: 640px;
cursor: pointer;
user-select: none;
}
.quote-text,
.quote-author {
transition: opacity 0.3s ease;
}
.quote-text {
font-size: 16px;
line-height: 1.8;
color: var(--el-text-color-regular);
}
.quote-author {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.quote-fade-in {
opacity: 1;
}
.quote-fade-out {
opacity: 0;
}
.welcome-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 20px;
}
/* 关键指标:KPI 数值区(无卡片、无装饰渐变,靠分隔线分区) */
.stat-grid {
display: grid;
@@ -138,6 +296,11 @@ const roleLabel = computed(() => {
.stat-cell {
padding: 4px 20px;
/* 首个单元去掉左内边距,与面板标题左对齐(其余靠 20px 内边距形成分区留白) */
&:first-child {
padding-left: 0;
}
& + .stat-cell {
border-left: 1px solid var(--el-border-color-lighter);
}
@@ -157,7 +320,11 @@ const roleLabel = computed(() => {
color: var(--el-text-color-secondary);
}
/* 快捷入口:图标宫格 */
/* 快捷入口:图标宫格(面板与欢迎区共用纵向空间,禁止被压缩) */
.dashboard > .gerp-panel {
flex-shrink: 0;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
@@ -196,4 +363,18 @@ const roleLabel = computed(() => {
text-overflow: ellipsis;
max-width: 100%;
}
/* 折叠开关:虚线描边与彩色图标区分"可展开",不参与跳转 */
.quick-item-toggle {
border-color: var(--el-border-color);
border-style: dashed;
&:hover {
border-color: var(--el-color-primary-light-5);
}
.quick-item-icon {
color: var(--el-text-color-secondary);
}
}
</style>