企业级门户网站设计与实现:基于SpringBoot + Vue3的全栈解决方案(Day 5)

功能(热门分类、导航链接等)完善与组件化开发

前言

前四天的开发工作完成了项目的基础架构、首页开发、文章列表页面、文章详情页面、公共组件抽离和功能完善。第五天的开发工作将重点关注组件化开发与交互优化,包括顶部导航组件化、热门分类动态数据获取、栏目激活状态优化以及底部组件复用,进一步提升代码的可维护性和用户体验。

一、顶部导航组件化

1. 组件抽离背景

在之前的开发中,首页、文章列表页和文章详情页都包含了相同的顶部导航代码。这导致了代码重复和维护困难的问题。为了解决这个问题,我们将顶部导航抽离成一个独立的公共组件 Header.vue

2. 组件设计

创建 src/components/Header.vue

复制代码
<template>
  <header class="sticky top-0 z-50 w-full border-b bg-white/95 backdrop-blur">
    <div class="container mx-auto px-4">
      <div class="flex items-center justify-between h-16">
        <!-- Logo -->
        <div class="flex items-center gap-3">
          <div class="w-10 h-10 rounded-lg bank-gradient flex items-center justify-center">
            <span class="text-white font-bold text-lg">BT</span>
          </div>
          <span class="font-bold text-xl text-slate-800">银科圈</span>
        </div>

        <!-- Desktop Navigation -->
        <nav class="hidden md:flex items-center gap-1">
          <template v-for="item in navList" :key="item.id">
            <!-- 一级栏目且有二级栏目:点击不跳转,显示下拉菜单 -->
            <div v-if="item.children && item.children.length > 0" class="relative group">
              <a href="#" @click.prevent :class="[
                'px-3 py-2 text-sm font-medium flex items-center gap-1 relative',
                activeNavId === item.id ? 'text-bank-primary bg-slate-50 rounded-md' : 'text-slate-600 hover:text-bank-primary hover:bg-slate-50 rounded-md transition-colors'
              ]">
                {{ item.name }}
                <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
                </svg>
              </a>
              <!-- 二级导航下拉菜单 -->
              <div class="absolute left-0 top-full mt-1 w-40 bg-white rounded-lg shadow-lg border border-slate-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50">
                <a v-for="child in item.children" :key="child.id" :href="`/list?categoryId=${child.id}`"
                   :class="['block px-4 py-2 text-sm relative', activeNavId === child.id ? 'text-bank-primary bg-slate-50 first:rounded-t-lg last:rounded-b-lg' : 'text-slate-600 hover:text-bank-primary hover:bg-slate-50 first:rounded-t-lg last:rounded-b-lg']">
                  {{ child.name }}
                </a>
              </div>
            </div>
            <!-- 没有二级导航的一级栏目 -->
            <a v-else :href="item.id === '2045904451398049793' ? '/' : `/list?categoryId=${item.id}`"
               :class="['px-3 py-2 text-sm font-medium rounded-md transition-colors relative', activeNavId === item.id ? 'text-bank-primary bg-slate-50' : 'text-slate-600 hover:text-bank-primary hover:bg-slate-50']">
              {{ item.name }}
            </a>
          </template>
        </nav>

        <!-- Search Box -->
        <div class="hidden md:flex items-center gap-4">
          <div class="relative">
            <input type="text" placeholder="搜索文章..." class="w-48 px-4 py-2 pl-10 text-sm border border-slate-200 rounded-lg focus:outline-none focus:border-bank-primary">
            <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
            </svg>
          </div>
        </div>

        <!-- Mobile Menu Button -->
        <button @click="toggleMobileMenu" class="md:hidden p-2 text-slate-600 hover:text-bank-primary">
          <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path v-if="!mobileMenuOpen" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
            <path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
          </svg>
        </button>
      </div>

      <!-- Mobile Menu -->
      <div v-if="mobileMenuOpen" class="md:hidden py-4 border-t">
        <nav class="space-y-2">
          <template v-for="item in navList" :key="item.id">
            <!-- 如果有二级导航 -->
            <div v-if="item.children && item.children.length > 0">
              <div @click.prevent :class="['block px-3 py-2 text-base font-medium cursor-pointer', activeNavId === item.id ? 'text-bank-primary bg-slate-50 rounded-md' : 'text-slate-700 hover:text-bank-primary']">{{ item.name }}</div>
              <a v-for="child in item.children" :key="child.id" :href="`/list?categoryId=${child.id}`"
                 :class="['block px-6 py-2 text-base font-medium pl-4 border-l-2 border-slate-200', activeNavId === child.id ? 'text-bank-primary bg-slate-50' : 'text-slate-600 hover:text-bank-primary']">
                {{ child.name }}
              </a>
            </div>
            <!-- 如果没有二级导航 -->
            <a v-else :href="item.id === '2045904451398049793' ? '/' : `/list?categoryId=${item.id}`"
               :class="['block px-3 py-2 text-base font-medium', activeNavId === item.id ? 'text-bank-primary bg-slate-50 rounded-md' : 'text-slate-700 hover:text-bank-primary']">
              {{ item.name }}
            </a>
          </template>
        </nav>
      </div>
    </div>
  </header>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { getFullNavList } from '../api/nav';

// 导航数据
const navList = ref([]);
const fullNavList = ref([]);
const mobileMenuOpen = ref(false);

// 获取当前路由
const route = useRoute();

// 计算当前激活的导航项ID
const activeNavId = computed(() => {
  // 首页路径
  if (route.path === '/') {
    return '2045904451398049793'; // 首页栏目ID
  }
  // 文章列表页面,从URL参数获取categoryId
  if (route.path === '/list' && route.query.categoryId) {
    const categoryId = route.query.categoryId;
    // 如果是二级栏目,返回对应的一级栏目ID
    if (childToParentMap.value[categoryId]) {
      return childToParentMap.value[categoryId];
    }
    return categoryId;
  }
  return '';
});

// 二级栏目到一级栏目的映射
const childToParentMap = ref({});

// 获取完整导航数据
async function fetchNavList() {
  try {
    const data = await getFullNavList();
    if (data.success) {
      fullNavList.value = data.result;
      // 构建一级栏目列表
      navList.value = data.result.filter(item => item.level === 1 && item.id !== '2045904451398049793');
      // 构建二级栏目到一级栏目的映射
      navList.value.forEach(parent => {
        if (parent.children) {
          parent.children.forEach(child => {
            childToParentMap.value[child.id] = parent.id;
          });
        }
      });
    }
  } catch (error) {
    console.error('获取导航数据失败:', error);
  }
}

// 切换移动菜单
function toggleMobileMenu() {
  mobileMenuOpen.value = !mobileMenuOpen.value;
}

onMounted(() => {
  fetchNavList();
  // 初始化Lucide图标
  if (window.lucide) {
    window.lucide.createIcons();
  }
});
</script>

<style scoped>
.bank-gradient {
  background: linear-gradient(135deg, #1a56db 0%, #0e7490 100%);
}
</style>

3. 关键功能实现

3.1 路由感知的激活状态

使用 Vue Router 的 useRoute 获取当前路由信息,根据路由路径和参数动态判断激活状态:

复制代码
const route = useRoute();

const activeNavId = computed(() => {
  // 首页路径
  if (route.path === '/') {
    return '2045904451398049793';
  }
  // 文章列表页面,从URL参数获取categoryId
  if (route.path === '/list' && route.query.categoryId) {
    const categoryId = route.query.categoryId;
    // 如果是二级栏目,返回对应的一级栏目ID
    if (childToParentMap.value[categoryId]) {
      return childToParentMap.value[categoryId];
    }
    return categoryId;
  }
  return '';
});
3.2 二级栏目到一级栏目的映射

构建映射表,实现选择二级栏目时高亮对应的一级栏目:

复制代码
const childToParentMap = ref({});

navList.value.forEach(parent => {
  if (parent.children) {
    parent.children.forEach(child => {
      childToParentMap.value[child.id] = parent.id;
    });
  }
});
3.3 点击逻辑处理
  • 首页(ID: 2045904451398049793):点击跳转到首页 /

  • 有二级导航的一级栏目:点击不跳转(添加 @click.prevent),显示下拉菜单

  • 没有二级导航的一级栏目:点击跳转到 /list?categoryId=xxx

  • 二级栏目:点击跳转到 /list?categoryId=xxx

3.4 鼠标划过效果

参考稀土掘金的样式,添加导航项的划过效果:

复制代码
/* 导航项划过效果 */
nav a:hover::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 20px;
  height: 2px;
  background-color: #1a56db;
  transition: width 0.2s ease;
}

/* 首页(激活状态)下划线 */
nav > a:first-child::after,
nav > div:first-within a::after {
  width: 20px;
}

/* 二级导航项下划线 */
nav a + div a::after {
  left: 12px;
  transform: none;
}

4. 组件使用

修改各页面,移除重复的导航代码,改为引用 Header 组件:

复制代码
<template>
  <div class="font-sans antialiased bg-slate-50 min-h-screen">
    <!-- Header -->
    <Header />

    <!-- Main Content -->
    <main>
      <!-- 内容区域 -->
    </main>

    <!-- Footer -->
    <Footer />
  </div>
</template>

<script setup>
import Header from '../components/Header.vue';
import Footer from '../components/Footer.vue';
// 其他逻辑
</script>

二、热门分类动态数据

1. 功能需求

首页的热门分类板块需要动态显示每个一级栏目下的前四个二级栏目及其文章数量,点击"查看全部"跳转到所有栏目的文章列表。

2. 实现思路

  1. fullNavList 获取栏目数据

  2. 根据栏目名称获取对应的一级栏目

  3. 筛选出该一级栏目下的所有二级栏目

  4. 按文章数量排序并取前四个

  5. 显示二级栏目的名称和文章数量

  6. "查看全部"链接指向 /list(所有文章)

3. 核心函数实现

复制代码
// 根据栏目名称获取前四个二级栏目
function getTopFourSubcategories(categoryName) {
  const category = fullNavList.value.find(item => item.name === categoryName && item.level === 1);
  if (category) {
    // 获取所有二级栏目
    const childCategories = fullNavList.value.filter(item => item.pid === category.id);
    // 按文章数量排序并取前四个
    return childCategories
      .sort((a, b) => (categoryArticleCounts.value[b.id] || 0) - (categoryArticleCounts.value[a.id] || 0))
      .slice(0, 4);
  }
  return [];
}

// 根据栏目ID获取文章数量
function getArticleCountById(categoryId) {
  return categoryArticleCounts.value[categoryId] || 0;
}

// 根据栏目名称获取栏目ID
function getCategoryIdByName(categoryName) {
  const category = fullNavList.value.find(item => item.name === categoryName && item.level === 1);
  return category ? category.id : '';
}

4. 模板修改

修改首页热门分类板块,使用动态数据:

复制代码
<!-- Tech Category -->
<div class="bg-white rounded-xl bank-card-shadow overflow-hidden">
  <div class="p-4 flex items-center gap-3 bg-blue-500">
    <div class="w-10 h-10 rounded-lg bg-white/20 flex items-center justify-center">
      <i data-lucide="database" class="w-5 h-5 text-white"></i>
    </div>
    <div>
      <h3 class="font-semibold text-white">技术实战</h3>
      <p class="text-xs text-white/80">{{ getCategoryCountByName('技术实战') }} 篇文章</p>
    </div>
  </div>
  <div class="p-3">
    <div class="grid grid-cols-2 gap-2">
      <a v-for="subcategory in getTopFourSubcategories('技术实战')" :key="subcategory.id"
         :href="`/list?categoryId=${subcategory.id}`"
         class="flex items-center justify-between p-2.5 rounded-lg hover:bg-slate-50 transition-colors">
        <span class="text-sm text-slate-600 hover:text-bank-primary">{{ subcategory.name }}</span>
        <span class="text-xs text-slate-400">{{ getArticleCountById(subcategory.id) }}</span>
      </a>
    </div>
    <a href="/list" class="flex items-center justify-center gap-1 mt-3 pt-3 border-t text-sm text-slate-500 hover:text-bank-primary transition-colors">
      查看全部 <i data-lucide="chevron-right" class="w-4 h-4"></i>
    </a>
  </div>
</div>

三、底部组件复用

1. 组件抽离

将首页的底部 footer 代码抽离成独立的 Footer.vue 组件,便于统一管理和维护。

2. 组件实现

创建 src/components/Footer.vue

复制代码
<template>
  <footer class="bg-slate-800 text-white py-12">
    <div class="container mx-auto px-4">
      <div class="grid grid-cols-1 md:grid-cols-4 gap-8">
        <div>
          <div class="flex items-center gap-2 mb-4">
            <div class="w-10 h-10 rounded-lg bank-gradient flex items-center justify-center">
              <span class="text-white font-bold text-lg">BT</span>
            </div>
            <span class="font-bold text-lg">银科圈</span>
          </div>
          <p class="text-slate-400 text-sm">专注银行科技领域,分享技术干货与职场指南</p>
          <div class="flex gap-4 mt-4">
            <a href="#" class="text-slate-400 hover:text-white transition-colors">
              <i data-lucide="mail" class="w-5 h-5"></i>
            </a>
            <a href="#" class="text-slate-400 hover:text-white transition-colors">
              <i data-lucide="twitter" class="w-5 h-5"></i>
            </a>
            <a href="#" class="text-slate-400 hover:text-white transition-colors">
              <i data-lucide="github" class="w-5 h-5"></i>
            </a>
          </div>
        </div>

        <div>
          <h3 class="font-semibold mb-4">技术领域</h3>
          <ul class="space-y-2 text-sm text-slate-400">
            <li><a href="#" class="hover:text-white transition-colors">银行核心系统</a></li>
            <li><a href="#" class="hover:text-white transition-colors">信创改造</a></li>
            <li><a href="#" class="hover:text-white transition-colors">自动化测试</a></li>
            <li><a href="#" class="hover:text-white transition-colors">运维与灾备</a></li>
            <li><a href="#" class="hover:text-white transition-colors">分布式架构</a></li>
          </ul>
        </div>

        <div>
          <h3 class="font-semibold mb-4">职场指南</h3>
          <ul class="space-y-2 text-sm text-slate-400">
            <li><a href="#" class="hover:text-white transition-colors">晋升路线</a></li>
            <li><a href="#" class="hover:text-white transition-colors">面试技巧</a></li>
            <li><a href="#" class="hover:text-white transition-colors">薪资爆料</a></li>
            <li><a href="#" class="hover:text-white transition-colors">职业发展</a></li>
            <li><a href="#" class="hover:text-white transition-colors">项目管理</a></li>
          </ul>
        </div>

        <div>
          <h3 class="font-semibold mb-4">关于我们</h3>
          <ul class="space-y-2 text-sm text-slate-400">
            <li><a href="#" class="hover:text-white transition-colors">关于银科圈</a></li>
            <li><a href="#" class="hover:text-white transition-colors">联系方式</a></li>
            <li><a href="#" class="hover:text-white transition-colors">广告合作</a></li>
            <li><a href="#" class="hover:text-white transition-colors">加入我们</a></li>
            <li><a href="#" class="hover:text-white transition-colors">隐私政策</a></li>
          </ul>
        </div>
      </div>

      <div class="border-t border-slate-700 mt-8 pt-8 text-center text-sm text-slate-500">
        <p>© 2024 银科圈 YinKeQuan. 保留所有权利。</p>
      </div>

      <!-- Stats Bar -->
      <div class="mt-4 text-center text-sm text-slate-500">
        <div class="flex flex-wrap items-center justify-center gap-6">
          <span>文章总数:1,234 篇</span>
          <span>|</span>
          <span>注册用户:56,789 人</span>
          <span>|</span>
          <span>今日访问:12,345 次</span>
          <span>|</span>
          <span>
            <i data-lucide="users" class="w-4 h-4 inline-block mr-1"></i>
            在线人数:1,234
          </span>
        </div>
      </div>
    </div>
  </footer>
</template>

<script setup>
import { onMounted } from 'vue';

onMounted(() => {
  if (window.lucide) {
    window.lucide.createIcons();
  }
});
</script>

<style scoped>
.bank-gradient {
  background: linear-gradient(135deg, #1a56db 0%, #0e7490 100%);
}
</style>

3. 组件使用

修改各页面,移除重复的 footer 代码,改为引用 Footer 组件:

复制代码
<template>
  <div class="font-sans antialiased bg-slate-50 min-h-screen">
    <!-- Header -->
    <Header />

    <!-- Main Content -->
    <main>
      <!-- 内容区域 -->
    </main>

    <!-- Footer -->
    <Footer />
  </div>
</template>

<script setup>
import Header from '../components/Header.vue';
import Footer from '../components/Footer.vue';
// 其他逻辑
</script>

四、交互优化

1. 一级栏目点击效果优化

移除原来不美观的 cursor-not-allowed 样式,改用 @click.prevent 阻止默认跳转行为:

复制代码
<!-- 桌面导航 -->
<div v-if="item.children && item.children.length > 0" class="relative group">
  <a href="#" @click.prevent
     :class="['px-3 py-2 text-sm font-medium flex items-center gap-1 relative',
       activeNavId === item.id ? 'text-bank-primary bg-slate-50 rounded-md' : 'text-slate-600 hover:text-bank-primary hover:bg-slate-50 rounded-md transition-colors']">
    {{ item.name }}
    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
    </svg>
  </a>
</div>

2. 移动端菜单优化

移动端的一级栏目使用 @click.preventcursor-pointer 样式:

复制代码
<!-- 移动菜单 -->
<div v-if="item.children && item.children.length > 0">
  <div @click.prevent :class="['block px-3 py-2 text-base font-medium cursor-pointer',
    activeNavId === item.id ? 'text-bank-primary bg-slate-50 rounded-md' : 'text-slate-700 hover:text-bank-primary']">
    {{ item.name }}
  </div>
  <a v-for="child in item.children" :key="child.id" :href="`/list?categoryId=${child.id}`"
     :class="['block px-6 py-2 text-base font-medium pl-4 border-l-2 border-slate-200',
       activeNavId === child.id ? 'text-bank-primary bg-slate-50' : 'text-slate-600 hover:text-bank-primary']">
    {{ child.name }}
  </a>
</div>

五、项目结构

经过五天的开发,项目结构如下:

复制代码
fintech-vue3/
├── src/
│   ├── api/
│   │   ├── article.ts          # 文章相关API
│   │   └── nav.ts             # 导航相关API
│   ├── components/
│   │   ├── Footer.vue         # 底部公共组件
│   │   └── Header.vue         # 顶部导航公共组件
│   ├── pages/
│   │   ├── Home.vue           # 首页
│   │   ├── ArticleList.vue    # 文章列表页
│   │   └── ArticleDetail.vue  # 文章详情页
│   ├── router/
│   │   └── index.js           # 路由配置
│   ├── style.css              # 全局样式
│   ├── App.vue                # 根组件
│   └── main.js                # 入口文件
├── public/
│   └── index.html
├── package.json
└── vite.config.js

六、后续计划

1. 功能扩展

  • 添加用户登录注册功能

  • 实现文章评论功能

  • 添加文章收藏功能

  • 实现用户个人中心

2. 性能优化

  • 实现图片懒加载

  • 添加骨架屏提升首屏体验

  • 优化列表滚动性能

  • 实现路由级别的代码分割

3. SEO优化

  • 添加 meta 标签

  • 实现服务端渲染(SSR)

  • 生成 sitemap.xml

  • 优化页面加载速度

4. 移动端适配

  • 优化移动端布局

  • 添加手势操作

  • 实现 PWA 功能

  • 适配深色模式

5. 部署上线

  • 配置 CI/CD 自动化部署

  • 设置生产环境配置

  • 配置 CDN 加速

  • 实施监控和日志系统

相关推荐
练习时长一年1 小时前
Spring配置类的演化
java·spring boot·spring
喜欢流萤吖~2 小时前
服务间的依赖管理:微服务的协作之道
java·微服务
invicinble2 小时前
Spring如何把bean注册到容器里
java·后端·spring
代码不加糖2 小时前
0基础搭建前后端分离项目:实现菜单与界面左右布局
java·前端·javascript·mysql·elementui·mybatis
希望永不加班2 小时前
SpringBoot 敏感数据脱敏(序列化层)
java·spring boot·后端·spring
希望永不加班2 小时前
SpringBoot 数据库索引优化:慢查询分析
java·数据库·spring boot·后端·spring
胡利光3 小时前
Harness Engineering 02|Repo Harness:让仓库对 Agent 可读
java·junit·单元测试
langsiming3 小时前
【无标题】
java·开发语言·数据库
weisian1513 小时前
Java并发编程--45-分布式一致性协议入门:Raft、Paxos与ZAB的核心思想
java·分布式·raft·paxos·zab