孟凡的博客blog.mengfanyaoapp.com
课程资料库›Vue 3 + Element Plus 前端开发›第10次 · 阶段实战:学员信息管理系统(综合第 1—9 课)
模块总览

第10次 · 阶段实战:学员信息管理系统(综合第 1—9 课)

2026-09-16 · Vue 3 + Element Plus 前端开发 · 课程笔记
课堂练习资料 10-阶段实战学员信息管理系统.zip
下载 ZIP 压缩包

第10次 · 阶段实战:学员信息管理系统(综合第 1—9 课)

第1/7页 · 先知道今天要做什么

前九课是把知识点一个个拆开练。今天不给新知识,只做一件事:把它们拼起来。真实项目里不会有人告诉你“现在是第几课、该用哪个指令”,你面对的是一句需求:“做一个学员信息管理页面”。今天的任务就是把这句需求变成能跑的东西。

这一课还有一个隐藏目的:暴露“单独会写、合起来不会用”的问题。所以代码里每个知识点怎么落到哪一行,我都在第 4 页做了对照表,你做完之后可以自查。

今天的场景(完整需求)

学员信息管理系统,包含:

  1. 顶部展示统计:总人数、已提交人数、平均分;
  2. 工具栏:姓名搜索、班级筛选、状态筛选、排序方式、新增按钮;
  3. 主体区域:卡片视图与表格视图可切换;
  4. 新增/编辑:弹出对话框填表,有基本校验;
  5. 删除:带二次确认;
  6. 数据持久化:刷新页面后数据还在(存 localStorage);
  7. 页面打开时搜索框自动聚焦。

下课时拿出这三样结果

做到什么 在哪里看 完成的证据
综合运用前面所有知识点 整个页面 七项需求全部实现,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>

预期看见什么

  1. 顶部四张统计卡:总人数、已提交、未提交、平均分。
  2. 工具栏一排控件:搜索框(光标已自动在里面)、班级下拉、状态单选、排序下拉、视图切换、新增、恢复初始数据。
  3. 主体默认是卡片视图,三列排布,每张卡有三个按钮。
  4. 切到表格视图,同一批数据变成带边框斑马纹的表格,得分列可以点表头排序。
  5. 点“新增学员”,弹出对话框;保存后列表多一条,统计数字变化。
  6. 点“编辑”,对话框里带着原有数据;保存后卡片上的“更新于”日期变成今天。
  7. 点“删除”会二次确认;取消则什么都不发生。
  8. 按 F5 刷新页面:数据还在,且弹出“数据已从本地恢复”。
  9. 底部调试区能看到当前筛选排序后的简表。

先猜一猜,读完代码再回答

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页 · 换成自己的代码

你的任务

把“学员”换成你自己的主题,七项需求一个都不能少。可选主题:图书管理、商品管理、社团成员、任务清单、课程表。

要求:

  1. 数据结构自己定:至少 6 个字段,其中必须有一个数值字段(用于统计)和一个状态字段(用于筛选)。
  2. 统计四项:至少有一项是平均值或百分比。
  3. 筛选三维度:关键字 + 分类 + 状态,三者可叠加。
  4. 两种视图:卡片与表格,用动态组件切换。
  5. 增删改全齐:新增用对话框,编辑复用同一个对话框,删除要二次确认。
  6. 持久化:刷新后数据仍在,并提供“恢复初始数据”按钮。
  7. 自动聚焦:页面打开后搜索框获得焦点。

按这张操作单完成

步骤 改哪里 运行后应该看到什么
1 换主题 SEED 与所有字段名 页面显示你自己的数据。
2 换统计 stat computed 四项统计都对本。
3 换筛选 工具栏 + filtered 三维度可叠加。
4 换视图 CardView / TableView 两种视图都能显示并操作。
5 校验增删改 对话框 + 处理函数 三种操作都生效且有提示。
6 持久化 onMounted / watch / 恢复按钮 刷新后数据还在,按钮能清空。
7 自动聚焦 searchRef 光标自动进搜索框。

最低完成标准:先把这三项做实

检查项 通过条件
派生数据不落地 统计、筛选结果、排序结果在代码里都是 computed。
单一数据源 页面所有变化都来自同一份 students(或你改名的那个数组)。
可复现 刷新浏览器后,你自己做的所有修改都还在。

在本地写练习记录

  1. 抄下你自己版本的数据结构定义,并说明每个字段的作用。
  2. 记录第 6 步 deep: true 实验的实际结果与结论。
  3. 写下七项需求各自用了哪一课的知识(做第 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。

不要用这些办法“修复”

  1. 不要用 location.reload() 来“让数据刷新”,那会把用户填的东西全丢掉。
  2. 不要在多个地方各存一份筛选后的结果(既存 filtered 又存 pageList),派生数据只算一次。
  3. 不要为了“让数据刷新后还在”而把整个组件状态塞进 localStorage(包括对话框开关、视图选择)。只持久化真正的业务数据。

需要老师帮忙时交这些

需求里哪一条没实现、对应的代码片段、Console 报错原文、你的数据结构定义、你做了什么操作、期望与实际的结果。

老师巡视时只问三句话

  1. 这个数字是算出来的还是存下来的?为什么?(能指出是 computed 并说明理由)
  2. 页面上一共有几个地方能改数据?列出来。(能数清并说明都在同一份数据上)
  3. 刷新后数据为什么还在?读和写分别在哪一行?(能指出 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 的结果,破坏派生数据的纯洁性。
这个页面最大的风险点在哪? 数据被多处修改。所以本课把所有修改都收在同几个函数里。

一分钟现场演示指南

  1. 先演完整流程:新增一个人 → 编辑他的分数 → 删除另一个人 → 刷新页面证明数据还在。
  2. 再演三层联动:搜索 + 班级筛选 + 排序三件叠加,说明“条件一多也不会乱,因为算的逻辑都集中在 computed 里”。
  3. 收尾一句:“阶段一学的是内功。后面学 Element Plus,学的是招式——内功不扎实,招式会走样。”

交作业前最后检查

  • src/App.vue 与 练习记录.md 已保存。
  • 七项需求全部实现。
  • 刷新浏览器后数据仍在;点“恢复初始数据”能回到初始状态。
  • Console 无红色报错。

课后再做一个小挑战(可选)

给系统加一个“批量操作”:表格视图增加多选列(el-table 的 type="selection"),选中若干条后支持一次性删除。这条会在第 21 课正式讲,现在做不出来是正常的——但你可以先自己想想:多选选中的 id 应该存在哪里?它算不算派生数据?

来源:本课自编 Vue 3 前端核心技能实训 · 语法依据 Vue 3.5 官方文档(核心语法总览)· 组件依据 Element Plus 2.8 官方文档