第10次 · 阶段实战:学员信息管理系统(综合第 1—9 课)
第10次 · 阶段实战:学员信息管理系统(综合第 1—9 课)
第1/7页 · 先知道今天要做什么
前九课是把知识点一个个拆开练。今天不给新知识,只做一件事:把它们拼起来。真实项目里不会有人告诉你“现在是第几课、该用哪个指令”,你面对的是一句需求:“做一个学员信息管理页面”。今天的任务就是把这句需求变成能跑的东西。
这一课还有一个隐藏目的:暴露“单独会写、合起来不会用”的问题。所以代码里每个知识点怎么落到哪一行,我都在第 4 页做了对照表,你做完之后可以自查。
今天的场景(完整需求)
学员信息管理系统,包含:
- 顶部展示统计:总人数、已提交人数、平均分;
- 工具栏:姓名搜索、班级筛选、状态筛选、排序方式、新增按钮;
- 主体区域:卡片视图与表格视图可切换;
- 新增/编辑:弹出对话框填表,有基本校验;
- 删除:带二次确认;
- 数据持久化:刷新页面后数据还在(存
localStorage); - 页面打开时搜索框自动聚焦。
下课时拿出这三样结果
| 做到什么 | 在哪里看 | 完成的证据 |
|---|---|---|
| 综合运用前面所有知识点 | 整个页面 | 七项需求全部实现,Console 无红色报错。 |
| 说清每个知识点落在哪 | 自查对照表 | 能在代码里一一指出哪个功能用了第几课的知识。 |
| 能独立完成“需求 → 代码” | 自己的版本 | 把数据与字段换成你自己的主题,功能依旧完整。 |
你只需要带着这些基础来
第 1—9 课的全部内容。今天不引入任何新语法。你需要的只是把所有课件的 App.vue 找出来,对着抄结构。
本课时 45 分钟怎么上
| 时间 | 老师带着做 | 学生留下什么 |
|---|---|---|
| 0—5 分钟 | 只讲需求,先不给代码。让学生自己列出“要用到哪些知识点”。 | 一张自己写的知识点清单。 |
| 5—15 分钟 | 讲解整体结构:数据层 → 派生层 → 交互层 → 渲染层。 | 明白“先想数据,再想界面”。 |
| 15—38 分钟 | 粘贴本课 App.vue,逐项验证七条需求;再按清单改成自己的主题。 |
七项需求跑通。 |
| 38—45 分钟 | 抽查:随机指一个功能,问“这里用了哪一课学的什么”。 | 全员提交。 |
从哪里开始
复制第 9 课的工程,改名 lesson-vue-10。不需要新增依赖。
今天先掌握到这里
今天全部在单个 App.vue 内完成。从第 11 课开始进入 Element Plus 的组件专题,那时才会真正开始拆多个文件。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(核心语法总览)· 组件依据 Element Plus 2.8 官方文档
第2/7页 · 把新词说清楚
今天没有新词,只有“分层”
把页面想成四层,写代码时从上往下想,出问题时从下往上查:
| 层 | 是什么 | 本课里的东西 |
|---|---|---|
| ① 数据层 | 唯一的数据来源,能不改就不改 | students(ref)、form(reactive) |
| ② 派生层 | 由数据算出来的东西,不单独存 | 统计、筛选后的列表、排序后的列表 |
| ③ 交互层 | 用户操作会改数据 | 新增、编辑、删除、搜索、切换视图 |
| ④ 渲染层 | 把数据画出来 | v-for、v-if、组件、表格 |
这个顺序非常重要:新手最常见的写法是“先写界面,缺什么补什么”,结果数据结构乱、派生数据满地存、改一个功能牵动三处。先定数据结构,后面全是水到渠成。
本课需求与知识点的对应表
| 需求 | 用到的知识点 | 来自第几课 |
|---|---|---|
| 数据从哪来 | ref / reactive |
第 3 课 |
| 统计总人数、平均分 | computed 派生数据 |
第 4 课 |
| 筛选与排序联动 | computed + watch |
第 4、5 课 |
| 列表渲染 / 空状态 | v-for + :key、v-if / v-else |
第 5 课 |
| 搜索框、下拉、开关 | v-model 与各类控件 |
第 6 课 |
| 左侧筛选栏 / 卡片 | 组件 + props + emits |
第 7 课 |
| 卡片与表格切换 | 动态组件 <component :is> |
第 8 课 |
| 自动聚焦、刷新后数据还在 | 模板引用、onMounted、onUnmounted |
第 9 课 |
| 持久化写入 | watch + localStorage |
第 4、9 课 |
数据结构先定下来
// 一条学员记录长什么样,先写死这个结构
{
id: 1, // 唯一编号,用于 key 与查找
name: '张三', // 姓名
cls: '计算机1班', // 班级
score: 92, // 分数,未提交为 null
done: true, // 是否已提交
updatedAt: '09-01' // 最后修改时间
}
四个字段各有用途:id 用于定位,name 用于搜索,cls 用于筛选,done / score 用于统计与展示。写任何功能之前,先把这条结构定死。
持久化怎么做(三段式)
const KEY = 'lesson-vue-10-students'
// ① 读:挂载时从本地存储恢复
onMounted(() => {
const raw = localStorage.getItem(KEY)
if (raw) {
try { students.value = JSON.parse(raw) } catch (e) { console.warn('数据损坏,忽略', e) }
}
})
// ② 写:数据一变就存(deep 才能监听到数组内部字段的变化)
watch(students, (val) => {
localStorage.setItem(KEY, JSON.stringify(val))
}, { deep: true })
// ③ 清:提供一个“恢复初始数据”按钮,方便反复练习
function resetData() {
localStorage.removeItem(KEY)
students.value = JSON.parse(JSON.stringify(SEED))
}
注意两个坑: JSON.parse 可能抛错(用户手动改过存储值),所以要 try/catch;watch 数组要加 deep: true,否则改数组里某一项的字段不会触发。
只看这几行:整体骨架
<script setup>
import { ref, computed } from 'vue'
/* ① 数据层 */
const students = ref([ /* ... */ ])
/* ② 派生层 */
const filtered = computed(() => { /* 筛选 */ })
const sorted = computed(() => { /* 排序 */ })
/* ③ 交互层 */
function add() { /* ... */ }
function remove(id) { /* ... */ }
/* ④ 渲染层 */
</script>
<template>
<!-- 工具栏:v-model 绑筛选条件 -->
<!-- 主体:v-for 渲染 sorted,空则显示 el-empty -->
</template>
先口头预测:如果我把“搜索”写在 filtered 里、把“排序”写在 sorted 里,那么输入关键字时,排序会不会重新算?
会。因为 sorted 依赖 filtered,filtered 一变化,sorted 就跟着重算。这就是第 4 课讲的“依赖链”,也是派生层这么设计的价值:你只需要管好依赖关系,剩下的交给 Vue。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(核心语法总览)· 组件依据 Element Plus 2.8 官方文档
第3/7页 · 先看一个小例子
完整代码:App.vue(学员信息管理系统)
<script setup>
import { ref, reactive, computed, watch, onMounted, nextTick, defineComponent, h } from 'vue'
import {
ElCard, ElInput, ElSelect, ElOption, ElRadioGroup, ElRadioButton, ElButton,
ElTable, ElTableColumn, ElTag, ElEmpty, ElDialog, ElForm, ElFormItem,
ElInputNumber, ElSwitch, ElMessage, ElMessageBox, ElDescriptions,
ElDescriptionsItem, ElStatistic, ElRow, ElCol, ElDivider, ElAlert
} from 'element-plus'
import 'element-plus/dist/index.css'
const KEY = 'lesson-vue-10-students'
/* ==================== ① 数据层 ==================== */
const SEED = [
{ id: 1, name: '张三', cls: '计算机1班', score: 92, done: true, updatedAt: '09-01' },
{ id: 2, name: '李四', cls: '计算机1班', score: null, done: false, updatedAt: '09-01' },
{ id: 3, name: '王五', cls: '计算机2班', score: 78, done: true, updatedAt: '09-02' },
{ id: 4, name: '赵六', cls: '计算机2班', score: 45, done: true, updatedAt: '09-02' },
{ id: 5, name: '钱七', cls: '计算机3班', score: null, done: false, updatedAt: '09-02' }
]
const students = ref(JSON.parse(JSON.stringify(SEED)))
// 筛选与排序条件(都是原始数据,用户控制)
const keyword = ref('')
const clsFilter = ref('all')
const statusFilter = ref('all') // all | done | todo
const sortBy = ref('none') // none | scoreDesc | scoreAsc | name
const view = ref('CardView') // CardView | TableView
// 新增/编辑对话框
const dialogVisible = ref(false)
const editingId = ref(null)
const form = reactive({ name: '', cls: '计算机1班', score: 60, done: false })
const searchRef = ref(null)
/* ==================== ② 派生层(全部 computed,不单独存) ==================== */
const filtered = computed(() => {
let arr = students.value
if (keyword.value.trim()) arr = arr.filter(s => s.name.includes(keyword.value.trim()))
if (clsFilter.value !== 'all') arr = arr.filter(s => s.cls === clsFilter.value)
if (statusFilter.value === 'done') arr = arr.filter(s => s.done)
if (statusFilter.value === 'todo') arr = arr.filter(s => !s.done)
return arr
})
const sorted = computed(() => {
const arr = [...filtered.value]
if (sortBy.value === 'scoreDesc') arr.sort((a, b) => (b.score ?? -1) - (a.score ?? -1))
if (sortBy.value === 'scoreAsc') arr.sort((a, b) => (a.score ?? 999) - (b.score ?? 999))
if (sortBy.value === 'name') arr.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
return arr
})
const stat = computed(() => {
const total = students.value.length
const doneList = students.value.filter(s => s.done)
const sum = doneList.reduce((acc, s) => acc + (s.score || 0), 0)
return {
total,
done: doneList.length,
todo: total - doneList.length,
avg: doneList.length ? (sum / doneList.length).toFixed(1) : '—'
}
})
const clsOptions = computed(() => ['all', ...new Set(students.value.map(s => s.cls))])
const preview = computed(() => JSON.stringify(sorted.value.map(s => ({ id: s.id, name: s.name, score: s.score })), null, 2))
/* ==================== ③ 交互层 ==================== */
function openAdd() {
editingId.value = null
Object.assign(form, { name: '', cls: '计算机1班', score: 60, done: false })
dialogVisible.value = true
}
function openEdit(row) {
editingId.value = row.id
Object.assign(form, { name: row.name, cls: row.cls, score: row.score ?? 60, done: row.done })
dialogVisible.value = true
}
function saveForm() {
if (form.name.trim().length < 2) { ElMessage.warning('姓名至少 2 个字'); return }
const today = new Date().toISOString().slice(5, 10)
if (editingId.value === null) {
students.value.push({ id: Date.now(), name: form.name.trim(), cls: form.cls, score: form.score, done: form.done, updatedAt: today })
ElMessage.success('新增成功')
} else {
const s = students.value.find(x => x.id === editingId.value)
if (s) Object.assign(s, { name: form.name.trim(), cls: form.cls, score: form.score, done: form.done, updatedAt: today })
ElMessage.success('修改已保存')
}
dialogVisible.value = false
}
function remove(id) {
const s = students.value.find(x => x.id === id)
ElMessageBox.confirm(`确定删除「${s ? s.name : ''}」吗?`, '二次确认', { type: 'warning' })
.then(() => {
students.value = students.value.filter(x => x.id !== id)
ElMessage.success('已删除')
})
.catch(() => ElMessage.info('已取消删除'))
}
function toggleDone(id) {
const s = students.value.find(x => x.id === id)
if (s) s.done = !s.done
}
function resetData() {
localStorage.removeItem(KEY)
students.value = JSON.parse(JSON.stringify(SEED))
ElMessage.info('已恢复初始数据')
}
function gradeType(score) {
if (score === null || score === undefined) return 'info'
if (score >= 85) return 'success'
if (score >= 60) return 'warning'
return 'danger'
}
/* ==================== 持久化 + 生命周期 ==================== */
// 读:页面打开时恢复
onMounted(async () => {
const raw = localStorage.getItem(KEY)
if (raw) {
try { students.value = JSON.parse(raw) } catch (e) { console.warn('本地数据损坏,已忽略', e) }
}
ElMessage.success('数据已从本地恢复(共 ' + students.value.length + ' 条)')
await nextTick()
if (searchRef.value) searchRef.value.focus()
})
// 写:数据一变就存;数组内部字段变化需要 deep
watch(students, (val) => {
localStorage.setItem(KEY, JSON.stringify(val))
}, { deep: true })
/* ==================== 子组件:卡片(props / emits / 插槽思想) ==================== */
const StudentCard = defineComponent({
name: 'StudentCard',
props: { student: { type: Object, required: true } },
emits: ['edit', 'remove', 'toggle'],
setup(props, { emit }) {
return () => h(ElCard, { class: 'stu', shadow: 'hover' }, {
default: () => [
h('div', { class: 'row' }, [
h('b', null, props.student.name),
h(ElTag, { size: 'small', type: props.student.done ? 'success' : 'info' },
() => (props.student.done ? '已提交' : '未提交'))
]),
h('p', { class: 'muted' }, `${props.student.cls} · 更新于 ${props.student.updatedAt}`),
h('p', null, ['得分:', h(ElTag, { size: 'small', type: gradeType(props.student.score) },
() => props.student.score ?? '—')]),
h('div', { class: 'ops' }, [
h(ElButton, { size: 'small', onClick: () => emit('toggle', props.student.id) }, () => '切换状态'),
h(ElButton, { size: 'small', type: 'primary', plain: true, onClick: () => emit('edit', props.student) }, () => '编辑'),
h(ElButton, { size: 'small', type: 'danger', plain: true, onClick: () => emit('remove', props.student.id) }, () => '删除')
])
]
})
}
})
/* ==================== 视图组件:卡片 / 表格(动态组件) ==================== */
const CardView = defineComponent({
props: { items: Array },
emits: ['edit', 'remove', 'toggle'],
setup(props, { emit }) {
return () => props.items.length
? h('div', { class: 'grid' }, props.items.map(s =>
h(StudentCard, {
key: s.id, student: s,
onEdit: (v) => emit('edit', v),
onRemove: (v) => emit('remove', v),
onToggle: (v) => emit('toggle', v)
})))
: h(ElEmpty, { description: '没有符合条件的学员' })
}
})
const TableView = defineComponent({
props: { items: Array },
emits: ['edit', 'remove'],
setup(props, { emit }) {
return () => h(ElTable, { data: props.items, size: 'default', border: true, stripe: true }, {
default: () => [
h(ElTableColumn, { type: 'index', label: '#', width: 60 }),
h(ElTableColumn, { prop: 'name', label: '姓名', width: 110 }),
h(ElTableColumn, { prop: 'cls', label: '班级', width: 130 }),
h(ElTableColumn, { prop: 'score', label: '得分', width: 90, sortable: true }),
h(ElTableColumn, { label: '状态', width: 100 }, {
default: ({ row }) => h(ElTag, { size: 'small', type: row.done ? 'success' : 'info' },
() => (row.done ? '已提交' : '未提交'))
}),
h(ElTableColumn, { label: '操作' }, {
default: ({ row }) => h('div', { class: 'ops' }, [
h(ElButton, { size: 'small', type: 'primary', plain: true, onClick: () => emit('edit', row) }, () => '编辑'),
h(ElButton, { size: 'small', type: 'danger', plain: true, onClick: () => emit('remove', row.id) }, () => '删除')
])
})
]
})
}
})
const views = { CardView, TableView }
</script>
<template>
<div class="page">
<h1 class="title">学员信息管理系统</h1>
<!-- ① 统计区 -->
<el-row :gutter="12" class="block">
<el-col :span="6"><el-card shadow="never"><el-statistic title="总人数" :value="stat.total" /></el-card></el-col>
<el-col :span="6"><el-card shadow="never"><el-statistic title="已提交" :value="stat.done" /></el-card></el-col>
<el-col :span="6"><el-card shadow="never"><el-statistic title="未提交" :value="stat.todo" /></el-card></el-col>
<el-col :span="6"><el-card shadow="never"><el-statistic title="平均分" :value="stat.avg" /></el-card></el-col>
</el-row>
<!-- ② 工具栏 -->
<el-card class="block">
<div class="toolbar">
<el-input ref="searchRef" v-model="keyword" placeholder="按姓名搜索" clearable style="width: 180px" />
<el-select v-model="clsFilter" style="width: 150px">
<el-option v-for="c in clsOptions" :key="c" :label="c === 'all' ? '全部班级' : c" :value="c" />
</el-select>
<el-radio-group v-model="statusFilter">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="done">已提交</el-radio-button>
<el-radio-button value="todo">未提交</el-radio-button>
</el-radio-group>
<el-select v-model="sortBy" style="width: 150px">
<el-option label="不排序" value="none" />
<el-option label="分数从高到低" value="scoreDesc" />
<el-option label="分数从低到高" value="scoreAsc" />
<el-option label="按姓名" value="name" />
</el-select>
<el-radio-group v-model="view">
<el-radio-button value="CardView">卡片</el-radio-button>
<el-radio-button value="TableView">表格</el-radio-button>
</el-radio-group>
<el-button type="primary" @click="openAdd">新增学员</el-button>
<el-button @click="resetData">恢复初始数据</el-button>
</div>
<el-divider style="margin: 12px 0" />
<p class="muted">当前显示 {{ sorted.length }} / {{ stat.total }} 条(筛选与排序后的结果)</p>
</el-card>
<!-- ③ 主体:动态组件 -->
<el-card class="block">
<component
:is="views[view]"
:items="sorted"
@edit="openEdit"
@remove="remove"
@toggle="toggleDone"
/>
</el-card>
<!-- ④ 新增 / 编辑对话框 -->
<el-dialog v-model="dialogVisible" :title="editingId === null ? '新增学员' : '编辑学员'" width="460px">
<el-form label-width="80px" @submit.prevent>
<el-form-item label="姓名">
<el-input v-model.trim="form.name" placeholder="至少 2 个字" />
</el-form-item>
<el-form-item label="班级">
<el-select v-model="form.cls" style="width: 100%">
<el-option v-for="c in clsOptions.filter(x => x !== 'all')" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="得分">
<el-input-number v-model="form.score" :min="0" :max="100" />
</el-form-item>
<el-form-item label="已提交">
<el-switch v-model="form.done" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveForm">保存</el-button>
</template>
</el-dialog>
<el-alert
title="刷新浏览器,数据仍然在(localStorage 持久化)。想重新开始就点“恢复初始数据”。"
type="success" :closable="false" show-icon
/>
<el-card class="block" style="margin-top: 16px">
<template #header>调试输出:当前 sorted 的简表(第 3 步自查用)</template>
<pre class="json">{{ preview }}</pre>
</el-card>
</div>
</template>
<style>
html, body, #app {
margin: 0; padding: 0; max-width: none; width: auto; height: auto;
display: block; place-items: initial; text-align: left; background: #f5f7fa;
}
</style>
<style scoped>
.page { max-width: 1080px; margin: 28px auto; padding: 0 16px; }
.title { font-size: 22px; color: #303133; margin: 0 0 18px; }
.block { margin-bottom: 16px; }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.muted { color: #909399; font-size: 13px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
:deep(.stu .row) { display: flex; justify-content: space-between; align-items: center; }
:deep(.stu .muted) { color: #909399; font-size: 12px; margin: 6px 0; }
:deep(.stu .ops) { display: flex; gap: 6px; flex-wrap: wrap; }
:deep(.json) {
background: #1f2430; color: #a9dc76; padding: 12px; border-radius: 6px;
font-size: 12px; line-height: 1.6; margin: 0; max-height: 240px; overflow: auto;
}
@media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
</style>
预期看见什么
- 顶部四张统计卡:总人数、已提交、未提交、平均分。
- 工具栏一排控件:搜索框(光标已自动在里面)、班级下拉、状态单选、排序下拉、视图切换、新增、恢复初始数据。
- 主体默认是卡片视图,三列排布,每张卡有三个按钮。
- 切到表格视图,同一批数据变成带边框斑马纹的表格,得分列可以点表头排序。
- 点“新增学员”,弹出对话框;保存后列表多一条,统计数字变化。
- 点“编辑”,对话框里带着原有数据;保存后卡片上的“更新于”日期变成今天。
- 点“删除”会二次确认;取消则什么都不发生。
- 按 F5 刷新页面:数据还在,且弹出“数据已从本地恢复”。
- 底部调试区能看到当前筛选排序后的简表。
先猜一猜,读完代码再回答
openAdd 里为什么用 Object.assign(form, {...}) 而不是直接 form = { ... }?
因为 form 是用 reactive 创建的,reactive 返回的是一个代理对象,直接给它整体赋值会把代理丢掉,页面就不再跟着变了。 正确做法是保留对象、改里面的字段——Object.assign 正好做这件事。这条规则在第 6 课排错表里出现过,今天的代码里再次用到,说明它是常用技巧而不是偏门知识。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(核心语法总览)· 组件依据 Element Plus 2.8 官方文档
第4/7页 · 一步一步做出来
第1步:先自己列清单(不看代码)
拿一张纸,把这个需求拆成“需要哪些数据 / 哪些是算出来的 / 用户能做什么操作 / 界面分几块”。预期:你会发现自己能列出大部分,但会漏掉一两项(常见漏项是“持久化”和“空状态”)。漏掉的地方就是你今天要重点看的地方。
第2步:只做数据层与统计
先只写 students 和 stat,模板里只显示那四张统计卡。预期:数字正确。此时还没有任何交互,但已经能验证数据结构是对的。
第3步:加筛选与排序(派生层)
写 filtered、sorted,模板里把 sorted.length 打印出来。预期:改工具栏的条件,数字跟着变。这一步不要写界面美化,先把“算得对”验证掉。
第4步:加渲染(卡片视图)
用 v-for 把卡片画出来,加上空状态。预期:数据几条就几张卡;筛到空时显示 el-empty。
第5步:加交互(新增 / 编辑 / 删除)
先做删除(最简单),再做新增,最后做编辑(因为要区分“新增”和“编辑”两种模式,用 editingId 是不是 null 判断)。
第6步:加持久化
加上 onMounted 读取与 watch 写入。预期:新增加一条后刷新页面,那条还在。
故意做一次错误实验:把 watch 的 { deep: true } 去掉,然后点“切换状态”。预期:界面会变(因为数组本身没变,但 Vue 的响应式仍然能通过 ref 的代理感知到——如果你用的是 ref + .value 直接改内部字段,通常仍会触发;但一旦改成整体替换或嵌套更深就会出问题)。这一条要自己验证并记下实际结果,理解 deep 到底管什么。
第7步:加模板引用与 nextTick
给搜索框加 ref="searchRef",在 onMounted 里 await nextTick() 后聚焦。
第8步:整段替换为本课完整版
把第 3 页的完整 App.vue 覆盖 src/App.vue,逐条对照第 1 页的七项需求打勾。
本课文件速查
| 位置 | 本次作用 | 对应课时 |
|---|---|---|
students |
唯一的数据来源 | 第 3 课 |
stat / filtered / sorted |
三组派生数据 | 第 4 课 |
v-for + :key、v-if/v-else |
列表与空状态 | 第 5 课 |
| 搜索框 / 下拉 / 单选 / 开关 | v-model 双向绑定 |
第 6 课 |
StudentCard(props + emits) |
组件通信 | 第 7 课 |
<component :is="views[view]"> |
卡片 / 表格切换 | 第 8 课 |
onMounted / nextTick / searchRef |
恢复数据与自动聚焦 | 第 9 课 |
watch(students, ..., { deep: true }) |
持久化写入 | 第 4、9 课 |
完成修改后检查
| 操作或输入 | 预期观察 |
|---|---|
| 搜索“张” | 只剩张三,统计卡不变(统计针对全部数据)。 |
| 选“计算机2班” | 只剩该班学员。 |
| 排序“分数从高到低” | 未提交的(null)排到最后。 |
| 新增后刷新 | 新数据还在。 |
| 点“恢复初始数据” | 回到 5 条初始数据,本地存储被清除。 |
| 编辑改分数 | 卡片与统计的平均分同步变化。 |
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(核心语法总览)· 组件依据 Element Plus 2.8 官方文档
第5/7页 · 换成自己的代码
你的任务
把“学员”换成你自己的主题,七项需求一个都不能少。可选主题:图书管理、商品管理、社团成员、任务清单、课程表。
要求:
- 数据结构自己定:至少 6 个字段,其中必须有一个数值字段(用于统计)和一个状态字段(用于筛选)。
- 统计四项:至少有一项是平均值或百分比。
- 筛选三维度:关键字 + 分类 + 状态,三者可叠加。
- 两种视图:卡片与表格,用动态组件切换。
- 增删改全齐:新增用对话框,编辑复用同一个对话框,删除要二次确认。
- 持久化:刷新后数据仍在,并提供“恢复初始数据”按钮。
- 自动聚焦:页面打开后搜索框获得焦点。
按这张操作单完成
| 步骤 | 改哪里 | 运行后应该看到什么 |
|---|---|---|
| 1 换主题 | SEED 与所有字段名 |
页面显示你自己的数据。 |
| 2 换统计 | stat computed |
四项统计都对本。 |
| 3 换筛选 | 工具栏 + filtered |
三维度可叠加。 |
| 4 换视图 | CardView / TableView |
两种视图都能显示并操作。 |
| 5 校验增删改 | 对话框 + 处理函数 | 三种操作都生效且有提示。 |
| 6 持久化 | onMounted / watch / 恢复按钮 |
刷新后数据还在,按钮能清空。 |
| 7 自动聚焦 | searchRef |
光标自动进搜索框。 |
最低完成标准:先把这三项做实
| 检查项 | 通过条件 |
|---|---|
| 派生数据不落地 | 统计、筛选结果、排序结果在代码里都是 computed。 |
| 单一数据源 | 页面所有变化都来自同一份 students(或你改名的那个数组)。 |
| 可复现 | 刷新浏览器后,你自己做的所有修改都还在。 |
在本地写练习记录
- 抄下你自己版本的数据结构定义,并说明每个字段的作用。
- 记录第 6 步
deep: true实验的实际结果与结论。 - 写下七项需求各自用了哪一课的知识(做第 1 页那张对照表的“你的版本”)。
最后交什么
提交 src/App.vue 与 练习记录.md。
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(核心语法总览)· 组件依据 Element Plus 2.8 官方文档
第6/7页 · 出错时从哪里查
把现象和检查动作对上
| 看到什么现象 / 报错 | 先检查哪里 | 修好后怎样确认 |
|---|---|---|
| 改了数据页面不更新 | 用了 reactive 又整体赋值(form = {...});或数据不是响应式的(例如存在普通变量里)。 |
改成 Object.assign 或 ref + .value。 |
| 刷新后数据丢失 | onMounted 里没读,或 watch 里没写,或 KEY 两边不一致。 |
确认读写的 KEY 完全相同。 |
| 刷新后白屏 / 报 JSON 解析错 | 本地存储里的内容被手动改坏。 | 加 try/catch,并在页面提供“恢复初始数据”。 |
| 统计数字把未提交的也算进去了 | 平均分计算时没先过滤 done。 |
先 filter(s => s.done) 再求和。 |
| 排序时未提交的一直排在前面 | 用了 (a.score ?? 0),导致 null 被当成 0。 |
用 ?? -1 / ?? 999 把 null 挤到一端。 |
| 编辑时对话框里是空数据 | 用了 editingId 但没把原数据 Object.assign 进 form。 |
点编辑时能看到原数据。 |
删除弹窗报 then of undefined |
记错了 ElMessageBox.confirm 的用法(它返回 Promise,不用自己 new)。 |
按本课写法调用后正常。 |
| 搜索框没有自动聚焦 | onMounted 里没等 nextTick,或 ref 名字不一致。 |
加上 await nextTick() 后正常。 |
新增后 key 重复警告 |
id: Date.now() 在极短时间内连点两次。 |
改成 Date.now() + Math.random() 或递增计数。 |
排错口诀
数据先定结构,再写界面; 派生一律 computed,别用 ref 存; reactive 要改字段,不能整体赋值; 持久化读写同一个 KEY,数组记得 deep。
不要用这些办法“修复”
- 不要用
location.reload()来“让数据刷新”,那会把用户填的东西全丢掉。 - 不要在多个地方各存一份筛选后的结果(既存
filtered又存pageList),派生数据只算一次。 - 不要为了“让数据刷新后还在”而把整个组件状态塞进
localStorage(包括对话框开关、视图选择)。只持久化真正的业务数据。
需要老师帮忙时交这些
需求里哪一条没实现、对应的代码片段、Console 报错原文、你的数据结构定义、你做了什么操作、期望与实际的结果。
老师巡视时只问三句话
- 这个数字是算出来的还是存下来的?为什么?(能指出是
computed并说明理由) - 页面上一共有几个地方能改数据?列出来。(能数清并说明都在同一份数据上)
- 刷新后数据为什么还在?读和写分别在哪一行?(能指出
onMounted与watch)
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(核心语法总览)· 组件依据 Element Plus 2.8 官方文档
第7/7页 · 用自己的话讲一遍
回到第 3 页:你刚才猜对了吗?
为什么 openAdd 必须用 Object.assign?
参考解释:reactive 返回的是代理对象,模板里的绑定指向这个代理。如果写 form = {...},变量 form 会被替换成一个普通对象,模板还盯着老代理,于是界面不再更新。Object.assign(form, {...}) 是“把新值写进原对象”,代理身份不变,响应性得以保留。
阶段一总结:这九课你到底学会了什么
| 课 | 一句话总结 |
|---|---|
| 1 | 用脚手架把一个页面跑起来,知道每个文件是干什么的。 |
| 2 | 一个 .vue 文件里怎么写模板、逻辑、样式。 |
| 3 | 数据用 ref / reactive 变得“会响应”。 |
| 4 | 能算出来的不要存,用 computed;要“做一件事”用 watch。 |
| 5 | 显示什么、显示几遍,交给 v-if 与 v-for。 |
| 6 | 用户能操作页面:事件 + v-model。 |
| 7 | 拆组件,父子之间用 props 与 emits 通信。 |
| 8 | 组件能被定制:插槽;能被切换:动态组件。 |
| 9 | 在正确的时刻做事,并在销毁时收尾。 |
阶段一的终点是:你能独立做完一个“纯前端、数据在本地”的小系统。 从第 11 课开始,进入 Element Plus 组件专题——那时你的页面会更专业,但底层用的还是这九课的东西。
五个必须能答上来的问题
| 考考自己 | 可以这样通俗回答 |
|---|---|
| 为什么统计要针对全部数据,而列表显示筛选后的数据? | 统计是“总体情况”,列表是“当前视角”,两者服务于不同目的。 |
| 新增和编辑共用一个对话框,靠什么区分? | 用一个 editingId:为 null 是新增,有值是编辑。 |
| 持久化只该存什么? | 只存业务数据;界面状态(对话框开关、选中视图)不该存。 |
| 为什么排序要复制一份数组再排? | sort() 会就地修改原数组,直接排会改到 filtered 的结果,破坏派生数据的纯洁性。 |
| 这个页面最大的风险点在哪? | 数据被多处修改。所以本课把所有修改都收在同几个函数里。 |
一分钟现场演示指南
- 先演完整流程:新增一个人 → 编辑他的分数 → 删除另一个人 → 刷新页面证明数据还在。
- 再演三层联动:搜索 + 班级筛选 + 排序三件叠加,说明“条件一多也不会乱,因为算的逻辑都集中在 computed 里”。
- 收尾一句:“阶段一学的是内功。后面学 Element Plus,学的是招式——内功不扎实,招式会走样。”
交作业前最后检查
src/App.vue与练习记录.md已保存。- 七项需求全部实现。
- 刷新浏览器后数据仍在;点“恢复初始数据”能回到初始状态。
- Console 无红色报错。
课后再做一个小挑战(可选)
给系统加一个“批量操作”:表格视图增加多选列(el-table 的 type="selection"),选中若干条后支持一次性删除。这条会在第 21 课正式讲,现在做不出来是正常的——但你可以先自己想想:多选选中的 id 应该存在哪里?它算不算派生数据?
来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(核心语法总览)· 组件依据 Element Plus 2.8 官方文档