feat: add Vue2 exercises for dynamic styles, lifecycle methods, component communication, and course management dashboard

- Implement dynamic styles and event handling in Vue2 with a card component.
- Create lifecycle methods exercise to simulate async data loading and instance destruction.
- Develop a component communication exercise with props, events, and slots.
- Build a comprehensive course management dashboard with filtering, statistics, and component interactions.
This commit is contained in:
charlie
2026-03-23 10:09:29 +08:00
parent 00d3c9e4c6
commit 3435848495
48 changed files with 1705 additions and 48 deletions

View File

@@ -0,0 +1,44 @@
# 练习 9Vue2 综合课程管理面板
## 目标
把 Vue2 入门阶段最关键的能力串起来,完成一个带搜索、筛选、统计、组件通信的课程管理面板。
## 你要练什么
- `new Vue()`
- `data`
- `methods`
- `computed`
- `watch`
- `v-model`
- `v-if`
- `v-show`
- `v-for`
- `:key`
- `:class`
- `props`
- `props` 校验
- `$emit`
- `slot`
- `ref`
## 任务
请基于页面结构完成以下操作:
- 输入关键字过滤课程名称
- 用下拉框切换“全部 / 已完成 / 学习中”
- 点击按钮让搜索框获得焦点
- 用子组件渲染每一项课程
- 子组件通过 `$emit` 通知父组件切换完成状态
- 没有匹配结果时显示空状态
- 页面底部显示总数、已完成数、当前筛选数
-`watch` 里输出关键字变化日志
## 文件
- [starter.html](/Users/lijiaqing/home/wwwroot/front-end-example/07-vue2/09-final-course-dashboard/starter.html)
- [starter.js](/Users/lijiaqing/home/wwwroot/front-end-example/07-vue2/09-final-course-dashboard/starter.js)
- [answer.html](/Users/lijiaqing/home/wwwroot/front-end-example/07-vue2/09-final-course-dashboard/answer.html)
- [answer.js](/Users/lijiaqing/home/wwwroot/front-end-example/07-vue2/09-final-course-dashboard/answer.js)

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue2 综合课程管理面板</title>
<style>
body { margin: 0; padding: 32px; font-family: "PingFang SC", sans-serif; background: #f4f7fb; }
.page { max-width: 960px; margin: 0 auto; display: grid; gap: 18px; }
.panel { padding: 22px; border-radius: 20px; background: #fff; border: 1px solid #d9e4f1; }
.toolbar { display: grid; grid-template-columns: 1fr 180px 160px; gap: 12px; }
input, select, button { padding: 12px 14px; border-radius: 12px; border: 1px solid #cad6e8; font: inherit; }
button { border: 0; background: #2d6cdf; color: #fff; cursor: pointer; }
.list { display: grid; gap: 14px; }
.course-card { padding: 18px; border-radius: 16px; border: 1px solid #dbe4f1; background: #fbfcfe; }
.course-card.is-done { border-color: #6cc18a; background: #f3fbf6; }
.meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.badge { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #edf4ff; color: #2d6cdf; font-size: 12px; }
.badge.advanced { background: #fff0e8; color: #d96a18; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.empty { padding: 18px; border-radius: 16px; background: #fff6e8; color: #a06200; }
</style>
</head>
<body>
<section id="app" class="page">
<article class="panel">
<h1>Vue2 课程管理面板</h1>
<div class="toolbar">
<input ref="searchInput" v-model="keyword" type="text" placeholder="输入课程关键字" />
<select v-model="statusFilter">
<option value="all">全部</option>
<option value="done">已完成</option>
<option value="doing">学习中</option>
</select>
<button type="button" @click="focusSearch">聚焦搜索框</button>
</div>
</article>
<section class="panel">
<div v-if="!filteredCourses.length" class="empty">暂无匹配结果</div>
<div v-show="filteredCourses.length" class="list">
<course-item
v-for="course in filteredCourses"
:key="course.id"
:course="course"
@toggle="toggleCourse"
>
<template v-slot:action>
切换完成状态
</template>
</course-item>
</div>
</section>
<footer class="panel stats">
<div>总课程数:{{ totalCount }}</div>
<div>已完成数:{{ finishedCount }}</div>
<div>当前筛选数:{{ visibleCount }}</div>
</footer>
</section>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="./answer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,99 @@
Vue.component("level-badge", {
props: {
level: {
type: String,
default: "基础",
},
},
computed: {
badgeClass() {
return this.level === "进阶" ? "badge advanced" : "badge";
},
},
template: `<span :class="badgeClass">{{ level }}</span>`,
});
const CourseItem = {
props: {
course: {
type: Object,
required: true,
},
},
template: `
<article class="course-card" :class="{ 'is-done': course.finished }">
<div class="meta">
<level-badge :level="course.level"></level-badge>
<strong>{{ course.title }}</strong>
</div>
<p>{{ course.finished ? "当前已完成" : "当前学习中" }}</p>
<button type="button" @click="$emit('toggle', course.id)">
<slot name="action">操作</slot>
</button>
</article>
`,
};
new Vue({
el: "#app",
components: {
CourseItem,
},
data: {
keyword: "",
statusFilter: "all",
courses: [
{ id: 1, title: "Vue 实例基础", finished: true, level: "基础" },
{ id: 2, title: "模板语法与指令", finished: false, level: "基础" },
{ id: 3, title: "组件通信实战", finished: false, level: "进阶" },
{ id: 4, title: "生命周期和异步", finished: true, level: "进阶" },
],
},
computed: {
filteredCourses() {
const keyword = this.keyword.trim().toLowerCase();
return this.courses.filter((course) => {
const matchStatus =
this.statusFilter === "all" ||
(this.statusFilter === "done" && course.finished) ||
(this.statusFilter === "doing" && !course.finished);
const matchKeyword = !keyword || course.title.toLowerCase().includes(keyword);
return matchStatus && matchKeyword;
});
},
totalCount() {
return this.courses.length;
},
finishedCount() {
return this.courses.filter((course) => course.finished).length;
},
visibleCount() {
return this.filteredCourses.length;
},
},
watch: {
keyword(newValue) {
console.log("搜索关键字变化:", newValue);
},
},
methods: {
toggleCourse(courseId) {
this.courses = this.courses.map((course) => {
if (course.id === courseId) {
return {
...course,
finished: !course.finished,
};
}
return course;
});
},
focusSearch() {
this.$refs.searchInput.focus();
},
},
});

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue2 综合课程管理面板</title>
<style>
body { margin: 0; padding: 32px; font-family: "PingFang SC", sans-serif; background: #f4f7fb; }
.page { max-width: 960px; margin: 0 auto; display: grid; gap: 18px; }
.panel { padding: 22px; border-radius: 20px; background: #fff; border: 1px solid #d9e4f1; }
.toolbar { display: grid; grid-template-columns: 1fr 180px 160px; gap: 12px; }
input, select, button { padding: 12px 14px; border-radius: 12px; border: 1px solid #cad6e8; font: inherit; }
button { border: 0; background: #2d6cdf; color: #fff; cursor: pointer; }
.list { display: grid; gap: 14px; }
.course-card { padding: 18px; border-radius: 16px; border: 1px solid #dbe4f1; background: #fbfcfe; }
.course-card.is-done { border-color: #6cc18a; background: #f3fbf6; }
.meta { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.badge { display: inline-flex; padding: 4px 10px; border-radius: 999px; background: #edf4ff; color: #2d6cdf; font-size: 12px; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.empty { padding: 18px; border-radius: 16px; background: #fff6e8; color: #a06200; }
</style>
</head>
<body>
<section id="app" class="page">
<article class="panel">
<h1>Vue2 课程管理面板</h1>
<div class="toolbar">
<input ref="searchInput" v-model="keyword" type="text" placeholder="输入课程关键字" />
<select v-model="statusFilter">
<option value="all">全部</option>
<option value="done">已完成</option>
<option value="doing">学习中</option>
</select>
<button type="button" @click="focusSearch">聚焦搜索框</button>
</div>
</article>
<section class="panel">
<div v-if="!filteredCourses.length" class="empty">暂无匹配结果</div>
<div v-show="filteredCourses.length" class="list">
<course-item
v-for="course in filteredCourses"
:key="course.id"
:course="course"
@toggle="toggleCourse"
>
<template v-slot:action>
切换完成状态
</template>
</course-item>
</div>
</section>
<footer class="panel stats">
<div>总课程数:{{ totalCount }}</div>
<div>已完成数:{{ finishedCount }}</div>
<div>当前筛选数:{{ visibleCount }}</div>
</footer>
</section>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<script src="./starter.js"></script>
</body>
</html>

View File

@@ -0,0 +1,79 @@
Vue.component("level-badge", {
props: {
level: {
type: String,
default: "基础",
},
},
template: `<span class="badge">{{ level }}</span>`,
});
const CourseItem = {
props: {
course: {
type: Object,
required: true,
},
},
template: `
<article class="course-card" :class="{ 'is-done': course.finished }">
<div class="meta">
<level-badge :level="course.level"></level-badge>
<strong>{{ course.title }}</strong>
</div>
<p>{{ course.finished ? "当前已完成" : "当前学习中" }}</p>
<button type="button" @click="$emit('toggle', course.id)">
<slot name="action">操作</slot>
</button>
</article>
`,
};
new Vue({
el: "#app",
components: {
CourseItem,
},
data: {
keyword: "",
statusFilter: "all",
courses: [
{ id: 1, title: "Vue 实例基础", finished: true, level: "基础" },
{ id: 2, title: "模板语法与指令", finished: false, level: "基础" },
{ id: 3, title: "组件通信实战", finished: false, level: "进阶" },
{ id: 4, title: "生命周期和异步", finished: true, level: "进阶" },
],
},
computed: {
filteredCourses() {
// 任务:
// 1. 先按 statusFilter 过滤
// 2. 再按 keyword 过滤标题
return this.courses;
},
totalCount() {
return this.courses.length;
},
finishedCount() {
// 任务:返回已完成课程数量
return 0;
},
visibleCount() {
// 任务:返回当前筛选后的数量
return 0;
},
},
watch: {
keyword(newValue) {
// 任务:在控制台输出关键字变化
},
},
methods: {
toggleCourse(courseId) {
// 任务:根据 courseId 切换 finished
},
focusSearch() {
// 任务:通过 ref 聚焦输入框
},
},
});