Skip to content

章节5 — 模块综合实战


一、章节概述

本章是模块二的综合实战环节,学员将运用前四章所学知识完成两个完整项目:一个企业官网响应式页面(HTML5 + CSS3 Flex/Grid + 媒体查询),和一个Todo 待办事项 Vue 应用(Vue3 + Pinia + Router)。通过从零到一的完整开发流程,巩固前端工程化思维。


二、学习目标

目标分类具体目标
知识目标理解从设计稿到代码的前端工作流;掌握 SPA 应用的项目结构与路由设计
技能目标能独立完成响应式企业官网页面开发;能使用 Vue3 + Pinia + Router 构建完整 SPA
素质目标培养项目组织、代码规范和自测习惯,建立前端职业自信

三、实战项目一:企业官网响应式页面

3.1 项目需求

项目名称:星河科技 — 企业品牌官网

设计需求

  1. 共 5 个区块:顶部导航(Nav)、首屏 Banner(Hero)、业务介绍(Services)、团队展示(Team)、底部信息(Footer)
  2. 桌面端 ≥ 1024px 显示完整布局
  3. 平板端 768~1023px 调整为双栏
  4. 手机端 < 768px 改为单列堆叠,导航折叠为汉堡菜单

3.2 项目结构

corporate-website/
├── index.html
├── css/
│   └── style.css
└── images/
    ├── hero-bg.jpg
    ├── service-1.svg
    ├── service-2.svg
    ├── service-3.svg
    └── team-1.jpg, team-2.jpg, team-3.jpg

3.3 代码实现

index.html

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>星河科技 — 数字化解决方案专家</title>
  <link rel="stylesheet" href="css/style.css" />
</head>
<body>
  <!-- 顶部导航 -->
  <header class="header">
    <div class="container header__inner">
      <a href="#" class="logo">✨ 星河科技</a>
      <button class="hamburger" id="hamburger" aria-label="菜单">☰</button>
      <nav class="nav" id="nav">
        <ul class="nav__list">
          <li><a href="#hero" class="nav__link active">首页</a></li>
          <li><a href="#services" class="nav__link">服务</a></li>
          <li><a href="#team" class="nav__link">团队</a></li>
          <li><a href="#contact" class="nav__link">联系</a></li>
        </ul>
      </nav>
    </div>
  </header>

  <!-- 首屏 Banner -->
  <section id="hero" class="hero">
    <div class="container hero__content">
      <h1 class="hero__title">用技术驱动商业未来</h1>
      <p class="hero__desc">我们为企业提供一站式数字化解决方案,助力业务增长</p>
      <a href="#services" class="btn btn--primary">了解服务</a>
    </div>
  </section>

  <!-- 业务介绍 -->
  <section id="services" class="services section">
    <div class="container">
      <h2 class="section__title">核心业务</h2>
      <div class="services__grid">
        <div class="service-card">
          <div class="service-card__icon">🌐</div>
          <h3>Web 开发</h3>
          <p>响应式网站、企业门户、电商平台全栈开发</p>
        </div>
        <div class="service-card">
          <div class="service-card__icon">📱</div>
          <h3>移动应用</h3>
          <p>跨平台 App 开发,iOS / Android 全覆盖</p>
        </div>
        <div class="service-card">
          <div class="service-card__icon">☁️</div>
          <h3>云服务</h3>
          <p>架构上云、容器化部署、性能优化</p>
        </div>
      </div>
    </div>
  </section>

  <!-- 团队展示 -->
  <section id="team" class="team section">
    <div class="container">
      <h2 class="section__title">核心团队</h2>
      <div class="team__grid">
        <article class="team-card">
          <img src="images/team-1.jpg" alt="张三" class="team-card__photo" />
          <h3>张三</h3>
          <p class="team-card__role">CEO & 创始人</p>
        </article>
        <article class="team-card">
          <img src="images/team-2.jpg" alt="李四" class="team-card__photo" />
          <h3>李四</h3>
          <p class="team-card__role">技术总监</p>
        </article>
        <article class="team-card">
          <img src="images/team-3.jpg" alt="王五" class="team-card__photo" />
          <h3>王五</h3>
          <p class="team-card__role">产品经理</p>
        </article>
      </div>
    </div>
  </section>

  <!-- 底部 -->
  <footer id="contact" class="footer">
    <div class="container footer__inner">
      <div class="footer__info">
        <p>© 2025 星河科技. All rights reserved.</p>
        <p>联系方式:contact@star-tech.com</p>
      </div>
      <div class="footer__social">
        <a href="#" aria-label="微信">💬 微信</a>
        <a href="#" aria-label="微博">📢 微博</a>
      </div>
    </div>
  </footer>

  <script>
    // 汉堡菜单交互
    document.getElementById("hamburger").addEventListener("click", () => {
      document.getElementById("nav").classList.toggle("nav--open");
    });

    // 滚动高亮当前导航
    const sections = document.querySelectorAll("section[id]");
    window.addEventListener("scroll", () => {
      const scrollY = window.scrollY;
      sections.forEach((sec) => {
        const top = sec.offsetTop - 100;
        const id = sec.getAttribute("id");
        if (scrollY >= top) {
          document.querySelectorAll(".nav__link").forEach((link) => {
            link.classList.toggle("active", link.getAttribute("href") === `#${id}`);
          });
        }
      });
    });
  </script>
</body>
</html>

css/style.css

css
/* ==================== Reset & Base ==================== */
*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
  color: #333;
  line-height: 1.6;
}

a {
  text-decoration: none;
  color: inherit;
}

ul {
  list-style: none;
}

img {
  max-width: 100%;
  height: auto;
  display: block;
}

/* ==================== Utility ==================== */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
}

.section {
  padding: 80px 0;
}

.section__title {
  text-align: center;
  font-size: 2rem;
  margin-bottom: 48px;
  position: relative;
}

.section__title::after {
  content: "";
  display: block;
  width: 60px;
  height: 4px;
  background: #42b883;
  margin: 12px auto 0;
  border-radius: 2px;
}

.btn {
  display: inline-block;
  padding: 12px 32px;
  border-radius: 6px;
  font-weight: 600;
  transition: all 0.3s ease;
}

.btn--primary {
  background: #42b883;
  color: #fff;
}

.btn--primary:hover {
  background: #38a376;
  transform: translateY(-2px);
}

/* ==================== Header ==================== */
.header {
  position: fixed;
  top: 0;
  width: 100%;
  background: rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(8px);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
  z-index: 1000;
}

.header__inner {
  display: flex;
  justify-content: space-between;
  align-items: center;
  height: 64px;
}

.logo {
  font-size: 1.4rem;
  font-weight: 700;
  color: #42b883;
}

.nav__list {
  display: flex;
  gap: 24px;
}

.nav__link {
  padding: 8px 0;
  border-bottom: 2px solid transparent;
  transition: border-color 0.3s;
}

.nav__link:hover,
.nav__link.active {
  border-bottom-color: #42b883;
  color: #42b883;
}

.hamburger {
  display: none;
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
}

/* ==================== Hero ==================== */
.hero {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: #fff;
  padding-top: 64px;
}

.hero__title {
  font-size: clamp(2rem, 5vw, 3.5rem);
  margin-bottom: 16px;
}

.hero__desc {
  font-size: 1.2rem;
  opacity: 0.9;
  margin-bottom: 32px;
}

/* ==================== Services (Grid) ==================== */
.services__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 32px;
}

.service-card {
  text-align: center;
  padding: 32px 24px;
  border-radius: 12px;
  background: #f8f9fa;
  transition: transform 0.3s, box-shadow 0.3s;
}

.service-card:hover {
  transform: translateY(-8px);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}

.service-card__icon {
  font-size: 3rem;
  margin-bottom: 16px;
}

.service-card h3 {
  margin-bottom: 8px;
}

/* ==================== Team (Flex) ==================== */
.team__grid {
  display: flex;
  gap: 32px;
  justify-content: center;
  flex-wrap: wrap;
}

.team-card {
  flex: 0 0 280px;
  text-align: center;
  padding: 24px;
  border-radius: 12px;
  background: #f8f9fa;
}

.team-card__photo {
  width: 120px;
  height: 120px;
  border-radius: 50%;
  object-fit: cover;
  margin: 0 auto 16px;
}

.team-card__role {
  color: #666;
  font-size: 0.9rem;
}

/* ==================== Footer ==================== */
.footer {
  background: #1a1a2e;
  color: #ccc;
  padding: 40px 0;
}

.footer__inner {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.footer__social {
  display: flex;
  gap: 16px;
}

.footer__social a {
  transition: color 0.3s;
}

.footer__social a:hover {
  color: #42b883;
}

/* ==================== 响应式设计 ==================== */

/* 平板端 768~1023px */
@media (max-width: 1023px) {
  .services__grid {
    grid-template-columns: repeat(2, 1fr);
  }

  .team-card {
    flex: 0 0 calc(50% - 16px);
  }
}

/* 手机端 < 768px */
@media (max-width: 767px) {
  .section {
    padding: 48px 0;
  }

  .hamburger {
    display: block;
  }

  .nav {
    display: none;
    position: absolute;
    top: 64px;
    left: 0;
    width: 100%;
    background: #fff;
    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  }

  .nav--open {
    display: block;
  }

  .nav__list {
    flex-direction: column;
    gap: 0;
  }

  .nav__link {
    display: block;
    padding: 16px 24px;
    border-bottom: 1px solid #eee;
  }

  .services__grid {
    grid-template-columns: 1fr;
  }

  .team-card {
    flex: 0 0 100%;
  }

  .footer__inner {
    flex-direction: column;
    gap: 16px;
    text-align: center;
  }
}

/* 桌面端 ≥ 1024px 已包含在基础样式中(移动优先的备用方案) */

四、实战项目二:Todo 待办事项 Vue 应用

4.1 项目需求

项目名称:TodoMaster — 待办事项管理应用

功能需求

  1. 用户可添加、删除、切换完成状态的待办事项
  2. 支持按「全部 / 进行中 / 已完成」筛选
  3. 每个待办事项可编辑标题
  4. 数据存储在 Pinia 中持久化到 localStorage
  5. 使用 Vue Router 实现页面切换(主页 + 关于页)
  6. 带有统计功能(总数、已完成数、完成率)

4.2 项目结构

todo-master/
├── index.html
├── src/
│   ├── main.js
│   ├── App.vue
│   ├── router/
│   │   └── index.js
│   ├── stores/
│   │   └── todo.js
│   ├── views/
│   │   ├── HomeView.vue
│   │   └── AboutView.vue
│   ├── components/
│   │   ├── TodoHeader.vue
│   │   ├── TodoInput.vue
│   │   ├── TodoList.vue
│   │   ├── TodoItem.vue
│   │   └── TodoFilter.vue
│   └── assets/
│       └── main.css
├── package.json
└── vite.config.js

4.3 代码实现

src/main.js

javascript
import { createApp } from "vue";
import { createPinia } from "pinia";
import router from "./router";
import App from "./App.vue";
import "./assets/main.css";

const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

src/router/index.js

javascript
import { createRouter, createWebHistory } from "vue-router";

const routes = [
  {
    path: "/",
    name: "Home",
    component: () => import("@/views/HomeView.vue"),
    meta: { title: "TodoMaster — 待办事项" },
  },
  {
    path: "/about",
    name: "About",
    component: () => import("@/views/AboutView.vue"),
    meta: { title: "关于 TodoMaster" },
  },
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

// 全局前置守卫 — 设置页面标题
router.beforeEach((to) => {
  document.title = to.meta?.title || "TodoMaster";
});

export default router;

src/stores/todo.js

javascript
import { defineStore } from "pinia";
import { ref, computed, watch } from "vue";

// 从 localStorage 读取数据
function loadTodos() {
  try {
    const saved = localStorage.getItem("todo-master");
    return saved ? JSON.parse(saved) : [];
  } catch {
    return [];
  }
}

export const useTodoStore = defineStore("todo", () => {
  // ===== State =====
  const todos = ref(loadTodos());
  const filter = ref("all"); // "all" | "active" | "completed"

  // ===== Getter =====
  const filteredTodos = computed(() => {
    switch (filter.value) {
      case "active":
        return todos.value.filter((t) => !t.done);
      case "completed":
        return todos.value.filter((t) => t.done);
      default:
        return todos.value;
    }
  });

  const stats = computed(() => {
    const total = todos.value.length;
    const done = todos.value.filter((t) => t.done).length;
    return {
      total,
      done,
      active: total - done,
      rate: total > 0 ? Math.round((done / total) * 100) : 0,
    };
  });

  // ===== Actions =====
  function addTodo(text) {
    const trimmed = text.trim();
    if (!trimmed) return;
    todos.value.push({
      id: Date.now(),
      text: trimmed,
      done: false,
      createdAt: new Date().toISOString(),
    });
  }

  function toggleTodo(id) {
    const todo = todos.value.find((t) => t.id === id);
    if (todo) todo.done = !todo.done;
  }

  function removeTodo(id) {
    todos.value = todos.value.filter((t) => t.id !== id);
  }

  function editTodo(id, newText) {
    const todo = todos.value.find((t) => t.id === id);
    if (todo) todo.text = newText.trim() || todo.text;
  }

  function clearCompleted() {
    todos.value = todos.value.filter((t) => !t.done);
  }

  // ===== 持久化 =====
  watch(
    todos,
    (val) => {
      localStorage.setItem("todo-master", JSON.stringify(val));
    },
    { deep: true }
  );

  return {
    todos,
    filter,
    filteredTodos,
    stats,
    addTodo,
    toggleTodo,
    removeTodo,
    editTodo,
    clearCompleted,
  };
});

src/App.vue

vue
<script setup>
import TodoHeader from "./components/TodoHeader.vue";
</script>

<template>
  <div class="app">
    <TodoHeader />
    <main class="container">
      <router-view />
    </main>
  </div>
</template>

src/components/TodoHeader.vue

vue
<script setup>
</script>

<template>
  <header class="app-header">
    <div class="container header-content">
      <router-link to="/" class="logo">✅ TodoMaster</router-link>
      <nav class="header-nav">
        <router-link to="/" class="nav-item" active-class="active">
          首页
        </router-link>
        <router-link to="/about" class="nav-item" active-class="active">
          关于
        </router-link>
      </nav>
    </div>
  </header>
</template>

<style scoped>
.app-header {
  background: #42b883;
  color: #fff;
  padding: 16px 0;
}

.header-content {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.logo {
  font-size: 1.4rem;
  font-weight: 700;
  color: #fff;
}

.header-nav {
  display: flex;
  gap: 20px;
}

.nav-item {
  color: rgba(255, 255, 255, 0.85);
  padding: 4px 0;
  border-bottom: 2px solid transparent;
  transition: 0.3s;
}

.nav-item:hover,
.nav-item.active {
  color: #fff;
  border-bottom-color: #fff;
}
</style>

src/views/HomeView.vue

vue
<script setup>
import { useTodoStore } from "@/stores/todo";
import { storeToRefs } from "pinia";
import TodoInput from "@/components/TodoInput.vue";
import TodoFilter from "@/components/TodoFilter.vue";
import TodoList from "@/components/TodoList.vue";

const store = useTodoStore();
const { stats, filteredTodos } = storeToRefs(store);
</script>

<template>
  <div class="home">
    <div class="stats-bar">
      <span>总计:<strong>{{ stats.total }}</strong></span>
      <span>已完成:<strong>{{ stats.done }}</strong></span>
      <span>完成率:<strong>{{ stats.rate }}%</strong></span>
    </div>

    <TodoInput />
    <TodoFilter />
    <TodoList :todos="filteredTodos" />
  </div>
</template>

<style scoped>
.home {
  max-width: 600px;
  margin: 0 auto;
  padding: 32px 0;
}

.stats-bar {
  display: flex;
  gap: 24px;
  justify-content: center;
  margin-bottom: 24px;
  padding: 12px 24px;
  background: #f8f9fa;
  border-radius: 8px;
  font-size: 0.95rem;
}

.stats-bar strong {
  color: #42b883;
}
</style>

src/views/AboutView.vue

vue
<script setup>
</script>

<template>
  <div class="about">
    <h1>关于 TodoMaster</h1>
    <p>TodoMaster 是一个基于 Vue 3 + Pinia + Vue Router 构建的待办事项管理应用。</p>
    <section class="tech-stack">
      <h2>技术栈</h2>
      <ul>
        <li>⚡ Vue 3 组合式 API + Script Setup</li>
        <li>📦 Pinia 状态管理</li>
        <li>🚦 Vue Router 路由管理</li>
        <li>💾 localStorage 数据持久化</li>
        <li>📱 响应式设计</li>
      </ul>
    </section>
  </div>
</template>

<style scoped>
.about {
  max-width: 600px;
  margin: 0 auto;
  padding: 48px 0;
  text-align: center;
}

.about h1 {
  margin-bottom: 16px;
  color: #42b883;
}

.tech-stack {
  margin-top: 32px;
  text-align: left;
  background: #f8f9fa;
  padding: 24px 32px;
  border-radius: 8px;
}

.tech-stack h2 {
  margin-bottom: 12px;
}

.tech-stack li {
  padding: 6px 0;
}
</style>

src/components/TodoInput.vue

vue
<script setup>
import { ref } from "vue";
import { useTodoStore } from "@/stores/todo";

const store = useTodoStore();
const text = ref("");

function submit() {
  store.addTodo(text.value);
  text.value = "";
}
</script>

<template>
  <form class="todo-input" @submit.prevent="submit">
    <input
      v-model="text"
      type="text"
      placeholder="输入新的待办事项..."
      class="input-field"
    />
    <button type="submit" class="btn-add">添加</button>
  </form>
</template>

<style scoped>
.todo-input {
  display: flex;
  gap: 8px;
  margin-bottom: 24px;
}

.input-field {
  flex: 1;
  padding: 12px 16px;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  font-size: 1rem;
  transition: border-color 0.3s;
}

.input-field:focus {
  outline: none;
  border-color: #42b883;
}

.btn-add {
  padding: 12px 24px;
  background: #42b883;
  color: #fff;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.3s;
}

.btn-add:hover {
  background: #38a376;
}
</style>

src/components/TodoFilter.vue

vue
<script setup>
import { useTodoStore } from "@/stores/todo";
import { storeToRefs } from "pinia";

const store = useTodoStore();
const { filter } = storeToRefs(store);

const filters = [
  { key: "all", label: "全部" },
  { key: "active", label: "进行中" },
  { key: "completed", label: "已完成" },
];
</script>

<template>
  <div class="todo-filter">
    <button
      v-for="f in filters"
      :key="f.key"
      :class="['filter-btn', { active: filter === f.key }]"
      @click="filter = f.key"
    >
      {{ f.label }}
    </button>
    <button class="clear-btn" @click="store.clearCompleted()">
      清除已完成
    </button>
  </div>
</template>

<style scoped>
.todo-filter {
  display: flex;
  gap: 8px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}

.filter-btn {
  padding: 6px 16px;
  border: 2px solid #e0e0e0;
  border-radius: 20px;
  background: #fff;
  cursor: pointer;
  transition: 0.3s;
}

.filter-btn.active {
  border-color: #42b883;
  background: #42b883;
  color: #fff;
}

.clear-btn {
  margin-left: auto;
  padding: 6px 16px;
  border: none;
  border-radius: 20px;
  background: #ff6b6b;
  color: #fff;
  cursor: pointer;
  font-size: 0.85rem;
}
</style>

src/components/TodoList.vue

vue
<script setup>
import TodoItem from "./TodoItem.vue";

defineProps({
  todos: { type: Array, required: true },
});
</script>

<template>
  <div class="todo-list">
    <p v-if="todos.length === 0" class="empty-hint">
      ✨ 暂无待办事项,添加一条吧!
    </p>
    <TransitionGroup name="list" tag="ul" class="todo-ul">
      <TodoItem v-for="todo in todos" :key="todo.id" :todo="todo" />
    </TransitionGroup>
  </div>
</template>

<style scoped>
.todo-ul {
  list-style: none;
}

.empty-hint {
  text-align: center;
  color: #999;
  padding: 48px 0;
}

/* 列表动画 */
.list-enter-active,
.list-leave-active {
  transition: all 0.4s ease;
}

.list-enter-from {
  opacity: 0;
  transform: translateX(-30px);
}

.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-move {
  transition: transform 0.4s ease;
}
</style>

src/components/TodoItem.vue

vue
<script setup>
import { ref } from "vue";
import { useTodoStore } from "@/stores/todo";

const props = defineProps({
  todo: { type: Object, required: true },
});

const store = useTodoStore();
const editing = ref(false);
const editText = ref("");

function startEdit() {
  editing.value = true;
  editText.value = props.todo.text;
}

function saveEdit() {
  store.editTodo(props.todo.id, editText.value);
  editing.value = false;
}

function cancelEdit() {
  editing.value = false;
}
</script>

<template>
  <li :class="['todo-item', { done: todo.done }]">
    <input
      type="checkbox"
      :checked="todo.done"
      @change="store.toggleTodo(todo.id)"
      class="todo-checkbox"
    />

    <!-- 编辑模式 -->
    <template v-if="editing">
      <input
        v-model="editText"
        type="text"
        class="edit-input"
        @keyup.enter="saveEdit"
        @keyup.escape="cancelEdit"
        ref="editInput"
      />
      <button class="btn-save" @click="saveEdit">保存</button>
      <button class="btn-cancel" @click="cancelEdit">取消</button>
    </template>

    <!-- 展示模式 -->
    <template v-else>
      <span class="todo-text" @dblclick="startEdit">{{ todo.text }}</span>
      <div class="todo-actions">
        <button class="btn-edit" @click="startEdit">✏️</button>
        <button class="btn-delete" @click="store.removeTodo(todo.id)">🗑️</button>
      </div>
    </template>
  </li>
</template>

<style scoped>
.todo-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 16px;
  background: #fff;
  border: 1px solid #eee;
  border-radius: 8px;
  margin-bottom: 8px;
  transition: box-shadow 0.3s;
}

.todo-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}

.todo-item.done .todo-text {
  text-decoration: line-through;
  color: #999;
}

.todo-checkbox {
  width: 20px;
  height: 20px;
  cursor: pointer;
  accent-color: #42b883;
}

.todo-text {
  flex: 1;
  cursor: pointer;
}

.todo-actions {
  display: flex;
  gap: 4px;
}

.btn-edit,
.btn-delete,
.btn-save,
.btn-cancel {
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 0.9rem;
}

.btn-save {
  background: #42b883;
  color: #fff;
  border-radius: 4px;
}

.btn-cancel {
  background: #eee;
  border-radius: 4px;
}

.edit-input {
  flex: 1;
  padding: 6px 12px;
  border: 2px solid #42b883;
  border-radius: 4px;
  font-size: 1rem;
}
</style>

src/assets/main.css(全局样式)

css
*,
*::before,
*::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
  background: #f0f2f5;
  color: #333;
  line-height: 1.6;
}

a {
  text-decoration: none;
  color: inherit;
}

ul {
  list-style: none;
}

.container {
  max-width: 768px;
  margin: 0 auto;
  padding: 0 20px;
}

可选:vite.config.js

javascript
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { fileURLToPath } from "url";

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
  },
});

package.json(依赖)

json
{
  "name": "todo-master",
  "private": true,
  "version": "1.0.0",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.3.0",
    "pinia": "^2.1.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "vite": "^5.0.0"
  }
}

4.4 启动项目

bash
# 1. 进入项目目录
cd todo-master

# 2. 安装依赖
npm install

# 3. 启动开发服务器
npm run dev
# → 浏览器访问 http://localhost:5173

五、实战要点总结

5.1 企业官网要点

技术点应用位置
HTML5 语义化标签<header><nav><section><article><footer>
Flex 布局导航栏、团队卡片行、页脚
Grid 布局业务服务卡片网格
媒体查询三断点(1024px / 768px)适配
交互汉堡菜单、滚动高亮导航
CSS 技巧clamp() 响应式字号、backdrop-filter 毛玻璃、.section__title::after 装饰线

5.2 TodoMaster 要点

技术点应用位置
Vue3 组合式 API所有组件均使用 <script setup>
PiniatodoStore 管理全部状态,storeToRefs 保持响应式
Vue Router主页 + 关于页,全局守卫设置标题
组件通信父传 props(TodoListTodoItem),子调 store action
本地持久化watch + localStorage 自动保存
过渡动画TransitionGroup 实现列表增删动画
键盘交互回车提交、Esc 取消编辑

六、课后拓展与练习

6.1 企业官网拓展需求

  1. 为官网添加「联系我们」表单,包含姓名、邮箱、留言,用 Fetch POST 到 https://jsonplaceholder.typicode.com/posts 并显示提交成功提示
  2. 添加「回到顶部」按钮,页面滚动超过 300px 时显示
  3. 使用 CSS @keyframes 为 Hero 区域的标题和按钮添加淡入上升动画

6.2 TodoMaster 拓展需求

  1. 添加「截止日期」功能:每个待办可设置到期日,过期时自动标红
  2. 按创建时间或到期日排序(正序/倒序切换)
  3. 添加「分类标签」:每个待办可选 Work / Personal / Study,并按标签筛选
  4. 使用 localforage 库替代 localStorage 实现更可靠的存储

6.3 综合思考题

  1. 如果将官网中的服务卡片从 Grid 改为 Flex,代码需要如何调整?哪种方案更适合?
  2. 如果 TodoMaster 需要支持多用户登录和云端同步,Pinia 的代码该如何改造?
  3. 分析企业官网和 TodoMaster 两个项目中,有哪些代码可以抽取为可复用的工具函数或组合式函数?

恭喜你完成模块二 — Web 前端开发的全部学习! 🎉

从 HTML5 语义化标签到 CSS3 布局,从 JavaScript 核心语法到 DOM 事件,从 Vue3 组件化到完整的实战项目,你已经掌握了现代 Web 前端开发的必备技能。接下来进入模块三 — Python 后端开发,继续你的全栈之旅!

Python 学习资料