主题
获取广告列表与分页(功能完善与部署 1/2)
本页对应 课堂视频,共 5 段视频,约 1 小时 48 分。
学习目标
- 能依据后端 API 文档说明获取广告列表接口的请求方式、分页参数、搜索参数及响应数据结构,并正确封装调用函数。
- 能使用获取到的广告数据完成列表渲染,并将广告类型编号映射为对应文字描述。
- 能实现分页页码渲染、当前页高亮、上一页/下一页及页码点击切换,并通过事件委托处理动态生成的按钮。
- 能实现搜索功能:读取搜索关键字、随列表请求携带关键字、搜索后分页保持关键字,并在添加广告后清空搜索框。
- 能复用同一个模态框实现添加与修改入口,完成根据 ID 获取广告详情、表单回填、图片预览、标题切换及表单重置。
- 能完成修改或添加提交后的列表刷新、模态框关闭及表单状态处理,保证列表数据与界面状态一致。
知识点
先封装获取广告列表 API
为什么需要:页面要展示广告,不能把请求地址散落在控制器里;统一封装后,分页、搜索、刷新列表都能复用。怎么想:后端接口是 GET /adv,查询参数有 pageNo、pageSize、keyword;GET 没有请求体,参数要放在 params 中。怎么做:在 src/api/adv.js 中新增 getAdv,形参名故意与后端参数名对齐,便于简写。易错点:调用时不要把参数包成一个对象再传给 getAdv,否则 pageNo 会变成对象;不传参也可以,后端有默认值:第 1 页、每页 3 条、关键字为空。
js
// src/api/adv.js
import advServer from '../request/advserver';
export const postAdv = formdata => {
return advServer.post('/adv', formdata)
}
export const getAdv = (pageNo, pageSize, keyword) => {
return advServer.get('/adv', {
params: { pageNo, pageSize, keyword }
})
}控制器里取数据并渲染表格组件
为什么需要:列表和分页都在模板里,但数据请求、类型转换、组件渲染属于控制逻辑,放在控制器更清晰。怎么想:渲染基础页面后立即调用 getAdvExec;接口返回 { data, pageNo, pageSum },其中 data 是当前页广告数组,pageSum 是总页数。advType 是数字,要转成中文描述。怎么做:用 await getAdv() 取数据,把 advList 和 advTypeDes 传给 AdvTableComponent,再写入 #advTable。易错点:图片字段 advPic 是后端路径,前端显示要加 /api/,借助代理转发到后端;否则会去前端静态服务器找图,出现 404。
js
// src/controllers/adv.js 关键片段
import AdvTableComponent from '../components/AdvTable';
import { postAdv, getAdv } from '../api/adv';
const getAdvExec = async () => {
const advTypeDes = {
1: '轮播图广告',
2: '轮播图底部广告',
3: '热门回收广告',
4: '优品精选广告'
};
const { data } = await getAdv();
document.querySelector('#advTable').innerHTML = AdvTableComponent({
advList: data,
advTypeDes
});
};组件中循环渲染并处理图片与类型
为什么需要:表格行数由后端数据决定,不能写死。怎么想:组件接收 advList 和 advTypeDes,用 EJS 循环生成 <tr>;类型用 advTypeDes[advItem.advType] 取中文;图片地址加 /api/。怎么做:保留表格和分页容器,分页后续再动态生成。易错点:advType 直接输出会是数字;图片 src 不能只写 advPic;字段名要按接口文档写,如 advTitle、advHref、orderNum、addTime、upTime。
ejs
<!-- src/components/AdvTable.ejs 关键片段 -->
<tbody>
<% data.advList.forEach(advItem => { %>
<tr>
<td><%= advItem.advTitle %></td>
<td><img height="80" src="/api/<%= advItem.advPic %>" alt="广告图片"></td>
<td><%= data.advTypeDes[advItem.advType] %></td>
<td><%= advItem.advHref %></td>
<td><%= advItem.orderNum %></td>
<td><%= advItem.addTime %></td>
<td><%= advItem.upTime %></td>
<td>
<button type="button" class="btn btn-danger btn-sm">删除</button>
<button type="button" data-id="<%= advItem._id %>" class="btn btn-success btn-sm btn-edit">修改</button>
</td>
</tr>
<% }) %>
</tbody>分页与搜索的调试要点
为什么需要:只取第一页不够,列表要能翻页、按标题搜索。怎么想:给 getAdvExec 增加 pageNo=1、pageSize=3 参数,并把 pageNo、pageSum 传给组件生成页码;搜索时在函数内部读取 #keyword 的值并传给 getAdv。怎么做:分页按钮用事件委托,委托到 #advTable 外层,因为组件内部每次刷新都会被替换;搜索按钮回调不要直接写 getAdvExec,否则事件对象会传进 pageNo,应包一层 () => getAdvExec()。易错点:分页事件若委托到组件内部会失效;添加成功后要刷新列表,并清空搜索框,避免新数据被旧关键字过滤掉。
js
// 搜索按钮:包一层,避免 event 被当成 pageNo
document.querySelector('#searchAdvBtn').addEventListener('click', () => {
getAdvExec();
});打开修改/添加模态框时的处理
为什么需要:添加和修改共用一个模态框,打开前必须判断是新增还是编辑。怎么想:点“修改”先根据 data-id 请求单条广告,再填充表单和预览图;点“添加”先重置表单、隐藏预览图、改标题。怎么做:API 增加 getAdvById(id);控制器用事件委托监听 .btn-edit,await getAdvById(id) 后给 document.addAdvForm 各输入框赋值;添加按钮去掉 data-toggle/data-target,自己监听并清空表单。易错点:修改后直接点添加会残留上次数据;预览图地址同样要加 /api/;模态框关闭可用 $('#advModal').modal('hide'),打开用 .modal('show')。
js
// src/api/adv.js 追加
export const getAdvById = id => {
return advServer.get(`/adv/${id}`)
}js
// 添加广告前:清空表单、隐藏预览图、改标题
document.addAdvForm.reset();
preImgEle.style.display = 'none';
document.querySelector('#advModal .modal-title').innerHTML = '添加广告';
$('#advModal').modal('show');整体流程是:API 封装 → 控制器取数 → 组件渲染 → 分页/搜索传参 → 添加或修改后刷新列表。调试时优先看 Network 里的请求参数和响应体,很多问题都是参数名、图片路径或事件对象误传造成的。
分页要解决什么问题
为什么需要:广告列表数据会越来越多,如果一次全部返回,页面渲染和查找都会变慢;后端因此提供分页参数,前端只请求当前页的数据。转写稿里后端接口有三个查询参数:pageNo(第几页)、pageSize(每页条数)、keyword(搜索关键字,本节先不展开)。响应里除了 data,还有 pageNo 和 pageSum:前者是当前页码,后者是总页数,不是每页条数。
怎么想:前端不自己切数组,而是把“第几页、每页几条”传给后端;拿到 data 后渲染表格,拿到 pageNo/pageSum 后渲染页码。点击页码时重新请求,只替换表格区域。
怎么做:在 API 层封装 getAdv,控制器里写 getAdvExec(no=1, size=3),解构响应并传给组件。
易错点:get 请求没有请求体,参数要放在 axios 的第二个配置对象里;转写稿中把默认 size 改成 4 后刷新,发现请求参数传错,原因是 getAdv 封装的是三个形参,不能把对象直接当第一个参数传。参数名要和后端一致,否则后端拿不到。
控制器里组织分页请求
为什么需要:页面初次打开要加载第一页;点击分页后也要复用同一套“请求—渲染”逻辑。
怎么想:把“获取广告列表并渲染”封装成 getAdvExec,默认 no=1,size=3;调用 getAdv(no,size) 后解构 data,pageNo,pageSum,再把 advList、advTypeDes、pageNo、pageSum 传给 AdvTableComponent。
怎么做:用 document.querySelector('#advTable').innerHTML = AdvTableComponent({...}) 替换表格区域。添加广告成功后也调用 getAdvExec() 更新列表。
易错点:pageSum 是总页数,循环页码时用 i <= pageSum;pageNo 用于判断当前页高亮。添加后如果搜索框有值,转写稿提醒要清空搜索框,避免新数据被旧关键字过滤;本节分页代码里至少要在添加后重置表单、隐藏预览图并刷新列表。
组件里渲染页码
为什么需要:用户要看到 1、2、3…页,并知道当前在第几页。
怎么想:用 for 循环从 1 到 pageSum 生成页码;当前页加 active;上一页传 pageNo-1,下一页传 pageNo+1。
怎么做:在 AdvTable.ejs 中写循环,data-i="<%= i %>" 保存页码;class="page-item <%= data.pageNo === i ? 'active' : '' %>" 控制高亮。
易错点:page-item 是 li 的类,page-link 是 a 的类,事件判断要用 page-link;越界页码(如第 1 页点上一页得到 0)转写稿说明后端会处理,前端不要自己编造复杂逻辑,但实际项目要与后端确认。
事件委托绑定分页点击
为什么需要:分页按钮在组件里,每次 getAdvExec 都会重新设置 #advTable 的 innerHTML,组件内部元素会被替换;如果直接给每个 a 绑定事件,刷新后就失效。
怎么想:把点击事件委托到不会被替换的外层 #advTable 上,点击后判断目标是否是 .page-link,再从 dataset.i 取页码。
怎么做:setPageExec 中判断 event.target.classList.contains('page-link'),成立则 getAdvExec(event.target.dataset.i);控制器里 document.querySelector('#advTable').addEventListener('click', setPageExec)。
易错点:不要委托到组件内部的 #pageDiv,它也会被重新渲染;转写稿还演示了搜索按钮直接写函数名会把 event 当成 no 传进去,分页这里因为显式取 dataset.i,所以不会把事件对象当页码,但回调参数仍要分清。
完整代码保留
js
// src/controllers/adv.js
import toastr from 'toastr';
// 导入模板页面
import advV from '@/views/adv';
// 导入组件
import AdvTableComponent from '../components/AdvTable';
// 导入api函数
import {postAdv, getAdv} from '../api/adv';
// 创建变量 记录预览图元素
let preImgEle;
// 执行获取广告列表并渲染页面
const getAdvExec = async (no=1, size=3) => {
// 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告
const advTypeDes = {
1: '轮播图广告',
2: '轮播图底部广告',
3: '热门回收广告',
4: '优品精选广告'
};
// 调用后端API获取数据
const {data, pageNo, pageSum} = await getAdv(no, size);
//console.log(res);
// 将数据传入组件
document.querySelector('#advTable').innerHTML = AdvTableComponent({
advList:data,
advTypeDes,
pageNo,
pageSum
});
};
// 执行分页渲染的函数
const setPageExec = event => {
// 判断是否是分页按钮
if (event.target.classList.contains('page-link')) {
getAdvExec(event.target.dataset.i);
}
}
// 执行图片预览
const prevImgExec = function() {
// 获取file对象
const file = this.files[0];
// 判断
if (!file) {
return;
}
// 实例化 FilerReader
const reader = new FileReader()
// 将文件读取成 base64 编码的形式 异步
reader.readAsDataURL(file);
// 监听读取完毕
reader.onload = () => {
// 将 base64编码的图片作为 src 属性的值
preImgEle.src = reader.result;
preImgEle.style.display = 'block';
}
}
// 执行广告添加
const addAdvExec = async event => {
// 阻止默认行为
event.preventDefault();
// 创建 FomrData 对象,以 form元素作为参数
const fd = new FormData(document.addAdvForm);
// 利用纯数组进行判断
const fdArr = Array.from(fd);
if (!fdArr.every(item => item[1])) {
toastr.error('请将表单填写完整!');
return;
}
if (fd.get('advPic').size === 0) {
toastr.error('请选择要上传的图片!');
return;
}
// 使用后端API添加广告
const res = await postAdv(fd)
console.log(res);
// 模态框关闭
$('#advModal').modal('hide');
// 表单清空
document.addAdvForm.reset();
// 隐藏预览图
preImgEle.style.display = 'none';
// 更新列表
getAdvExec();
}
export default (req, res) => {
// 渲染页面
res.render(advV());
// 获取广告列表并渲染到页面
getAdvExec();
// 点击分页按钮 事件委托
document.querySelector('#advTable').addEventListener('click', setPageExec);
// 获取预览图元素
preImgEle = document.querySelector('#preImg');
// 给图片上传input监听change事件,实现图片实时预览
document.querySelector('#advPic').addEventListener('change', prevImgExec);
// 给表单监听提交事件
document.addAdvForm.addEventListener('submit', addAdvExec);
}ejs
<!-- src/components/AdvTable.ejs -->
<div class="card-body table-responsive">
<table id="" class="table table-bordered">
<thead>
<tr>
<th>标题</th>
<th>图片</th>
<th>类别</th>
<th>链接</th>
<th>排序</th>
<th>添加时间</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<% data.advList.forEach(advItem => { %>
<tr>
<td><%= advItem.advTitle %></td>
<td><img height="80" src="/api/<%= advItem.advPic %>" alt="广告图片">
</td>
<td><%= data.advTypeDes[advItem.advType] %></td>
<td><%= advItem.advHref %></td>
<td><%= advItem.orderNum %></td>
<td><%= advItem.addTime %></td>
<td><%= advItem.upTime %></td>
<td>
<button type="button" class="btn btn-danger btn-sm">删除</button>
<button type="button" data-id="<%= advItem._id %>" class="btn btn-success btn-sm btn-edit">修改</button>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="card-footer clearfix" id="pageDiv">
<ul class="pagination pagination-sm m-0 float-right">
<li class="page-item">
<a href="javascript:;" data-i="<%= data.pageNo - 1 %>" class="page-link">«</a>
</li>
<% for (let i = 1; i <= data.pageSum; i ++) { %>
<li class="page-item <%= data.pageNo === i ? 'active' : '' %>">
<a class="page-link" data-i="<%= i %>" href="javascript:;"><%= i %></a>
</li>
<% } %>
<li class="page-item">
<a href="javascript:;" data-i="<%= data.pageNo + 1 %>" class="page-link">»</a>
</li>
</ul>
</div>搜索的需求与接口参数
为什么需要:广告列表数据会越来越多,用户必须能按广告标题快速筛选;同时搜索不能破坏分页,翻页后仍要沿用当前关键词。
怎么想:后端“获取广告列表”接口已经支持第三个查询参数 keyword,它只对广告标题 advTitle 做包含匹配;不传时为空字符串,相当于不过滤。
怎么做:前端不新增搜索接口,而是在调用 getAdv(pageNo, pageSize, keyword) 时把关键词传进去。
易错点:不要误以为搜索是独立接口;关键词为空也要能正常返回全部数据。
js
// src/controllers/adv.js(搜索相关核心代码)
const getAdvExec = async (no = 1, size = 3) => {
const advTypeDes = {
1: '轮播图广告',
2: '轮播图底部广告',
3: '热门回收广告',
4: '优品精选广告'
};
const kw = document.querySelector('#keyword').value.trim();
const { data, pageNo, pageSum } = await getAdv(no, size, kw);
document.querySelector('#advTable').innerHTML = AdvTableComponent({
advList: data,
advTypeDes,
pageNo,
pageSum
});
};在控制器内部读取关键词
为什么需要:如果只在点击搜索时临时取关键词,翻页时就会丢失搜索条件。
怎么想:把“读取搜索框”放进 getAdvExec 内部,这样首次渲染、点击搜索、点击分页都会走同一套逻辑。
怎么做:用 document.querySelector('#keyword').value.trim() 取关键词,再传给 getAdv。
易错点:忘记 trim() 会把首尾空格当关键词;搜索框选择器写错会直接报错。
转写稿中调试时,搜索“手机”后网络请求里的 pageNo 变成了 event 对象。原因是把 getAdvExec 直接写成了点击回调,浏览器会把事件对象作为第一个参数传给 no。正确做法是包一层:
js
document.querySelector('#searchAdvBtn').addEventListener('click', () => {
getAdvExec();
});分页与搜索联动
为什么需要:搜索“米”可能有多页,翻到第二页时仍应只显示含“米”的广告。
怎么想:分页按钮只负责传页码,关键词由 getAdvExec 内部统一读取。
怎么做:给分页区域做事件委托,判断 page-link,取 dataset.i 作为页码。
易错点:事件要委托给不会被组件整体替换的外层 #advTable,不要委托到组件内部元素上。
js
const setPageExec = event => {
if (event.target.classList.contains('page-link')) {
getAdvExec(event.target.dataset.i);
}
};
document.querySelector('#advTable').addEventListener('click', setPageExec);
document.querySelector('#searchAdvBtn').addEventListener('click', () => {
getAdvExec();
});添加成功后清空搜索框
为什么需要:用户搜索“米”后新增“沙宣”,如果搜索框还保留“米”,列表仍按旧关键词过滤,看起来像新广告没出现。
怎么想:添加成功后要刷新列表,同时清除旧搜索条件。
怎么做:在 addAdvExec 中关闭模态框、重置表单、隐藏预览图后,把 #keyword 置空,再调用 getAdvExec()。
易错点:只清空表单不清搜索框,会误以为新增失败;清空后要重新获取列表。
js
// addAdvExec 中添加成功后的处理片段
$('#advModal').modal('hide');
document.addAdvForm.reset();
preImgEle.style.display = 'none';
document.querySelector('#keyword').value = '';
getAdvExec();本节完整控制器代码保留
js
import toastr from 'toastr';
import advV from '@/views/adv';
import AdvTableComponent from '../components/AdvTable';
import { postAdv, getAdv } from '../api/adv';
let preImgEle;
const getAdvExec = async (no = 1, size = 3) => {
const advTypeDes = {
1: '轮播图广告',
2: '轮播图底部广告',
3: '热门回收广告',
4: '优品精选广告'
};
const kw = document.querySelector('#keyword').value.trim();
const { data, pageNo, pageSum } = await getAdv(no, size, kw);
document.querySelector('#advTable').innerHTML = AdvTableComponent({
advList: data,
advTypeDes,
pageNo,
pageSum
});
};
const setPageExec = event => {
if (event.target.classList.contains('page-link')) {
getAdvExec(event.target.dataset.i);
}
};
const prevImgExec = function () {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
preImgEle.src = reader.result;
preImgEle.style.display = 'block';
};
};
const addAdvExec = async event => {
event.preventDefault();
const fd = new FormData(document.addAdvForm);
const fdArr = Array.from(fd);
if (!fdArr.every(item => item[1])) {
toastr.error('请将表单填写完整!');
return;
}
if (fd.get('advPic').size === 0) {
toastr.error('请选择要上传的图片!');
return;
}
await postAdv(fd);
$('#advModal').modal('hide');
document.addAdvForm.reset();
preImgEle.style.display = 'none';
document.querySelector('#keyword').value = '';
getAdvExec();
};
export default (req, res) => {
res.render(advV());
getAdvExec();
document.querySelector('#advTable').addEventListener('click', setPageExec);
document.querySelector('#searchAdvBtn').addEventListener('click', () => {
getAdvExec();
});
preImgEle = document.querySelector('#preImg');
document.querySelector('#advPic').addEventListener('change', prevImgExec);
document.addAdvForm.addEventListener('submit', addAdvExec);
};先明确目标:一个模态框复用两种模式
添加广告和修改广告的字段完全一致,所以没必要写两个模态框。可以共用一个 #advModal,关键是在“打开”这一刻判断当前是添加还是修改:添加模式要清空表单、隐藏预览图、标题改成“添加广告”;修改模式要先按 id 取详情,把数据回填到表单,显示预览图,标题改成“修改广告”。
为什么需要这样处理?因为复用模态框后,上一次操作的状态会残留。比如先点“修改”,表单里已经有标题、链接、图片预览;再点“添加”,如果不清空,就会把旧数据带进添加流程。转写稿里就出现了这个问题:点添加后仍保留最后一次修改的信息。因此打开添加模态框时必须显式重置。
补 API:根据 id 获取单条广告
修改前需要拿到当前这条广告的详细信息,列表里的数据不一定够用,也不一定是最新的。笔记中在 src/api/adv.js 增加:
js
// 根据id获取广告信息
export const getAdvById = id => {
return advServer.get('/adv/' + id);
}怎么想:修改按钮上已经通过 data-id 记录了广告 id,点击后把 id 传给 getAdvById,后端返回单条广告对象。易错点:返回值通常是 { data },这里的 data 是对象,不是列表;不要把它当数组遍历。
控制器:打开添加广告模态框
js
const openAddModalExec = () => {
// 清空表单
document.addAdvForm.reset();
// 隐藏预览图
preImgEle.style.display = 'none';
// 修改标题
document.querySelector('#advModal .modal-title').innerHTML = '添加广告';
// 打开模态框
$('#advModal').modal('show');
}为什么需要:共用模态框时,添加模式必须是“干净”的。怎么做:先 reset() 清空表单,再隐藏 #preImg,改标题,最后调用 Bootstrap 的 modal('show')。易错点:reset() 不会自动隐藏你手动显示的预览图,必须单独设置 display: none;标题选择器要限定在 #advModal .modal-title,避免选错。
控制器:打开修改广告模态框
js
const openEditModalExec = async event => {
// 判断点击是修改按钮
if (event.target.classList.contains('btn-edit')) {
// 获取该条广告的信息
const {data} = await getAdvById(event.target.dataset.id);
// 将广告信息显示在表单里,作为表单控件的默认值
document.addAdvForm.advTitle.value = data.advTitle;
document.addAdvForm.advType.value = data.advType;
document.addAdvForm.advHref.value = data.advHref;
document.addAdvForm.orderNum.value = data.orderNum;
// 显示预览图
preImgEle.style.display = 'block';
preImgEle.src = '/api/' + data.advPic;
// 修改模态框标题
document.querySelector('#advModal .modal-title').innerHTML = '修改广告';
// 打开模态框
$('#advModal').modal('show');
}
}为什么需要:修改必须基于当前点击的那条数据。怎么想:先判断点击目标是不是 .btn-edit,再用 dataset.id 请求详情,拿到 data 后逐个回填表单控件的 value。易错点:异步请求要加 async/await;所有回填和打开模态框的代码都要放在 if 里面。转写稿里一开始把后续代码写在 if 外,点修改就报错,因为没拿到数据也继续执行。另一个易错点是图片地址:data.advPic 是后端返回的相对路径,直接写进 src 会请求前端静态服务器导致 404,所以要通过代理加 /api/ 前缀。
事件绑定与视图配合
视图里表单必须有稳定的 name 和 id,例如 form name="addAdvForm"、#advTitle、#advType、#advHref、#orderNum、#advPic、#preImg,模态框标题放在 #advModal .modal-title。控制器中绑定:
js
// 点击添加广告的按钮
document.querySelector('#addAdvBtn').addEventListener('click', openAddModalExec);
// 点击表格中的修改按钮 事件委托
document.querySelector('#advTable').addEventListener('click', openEditModalExec);为什么修改按钮要用事件委托?因为每次 getAdvExec() 重新渲染表格后,按钮都是新的,直接给每个按钮绑定会失效。委托到稳定的父容器 #advTable 上更合适。易错点:添加按钮不要保留 data-toggle、data-target 让 Bootstrap 自动打开,否则会绕过“清空表单、隐藏预览、改标题”的自定义逻辑;应删掉这些属性,统一由 openAddModalExec 手动 modal('show')。
调试检查清单
打开修改前,先确认按钮有 btn-edit 和 data-id;请求详情后打印 data,确认是对象;图片预览检查 src 是否为 /api/xxx;打开添加时检查表单是否重置、预览图是否隐藏、标题是否变为“添加广告”。这样,添加和修改共用一个模态框的流程就完整了。
核心判断:FormData 里有没有 id
本节要解决的是“添加广告”和“修改广告”共用一个模态框时,提交时到底走新增还是更新。笔记给出的判断依据很明确:根据 FormData 中 id 是否有值作为判断依据,有值修改,无值添加。
为什么需要这样判断?因为添加和修改的表单字段几乎一样,标题、类型、链接、排序、图片都相同,如果做两个模态框,结构重复、事件重复、维护成本高。怎么想?用一个隐藏域承载“当前操作的是哪条广告”的标识:添加时隐藏域为空,修改时把该条广告的 _id 写进去。怎么做?模板里放隐藏域,打开修改模态框时赋值,打开添加模态框时清空,提交时统一读 FormData。
ejs
<form name="addAdvForm" class="form-horizontal" id="addAdvForm">
<!-- 隐藏域:有值表示修改,无值表示添加 -->
<input type="hidden" name="id" value="">
<!-- 其他表单控件省略 -->
<div>
<img style="display: none;" id="preImg" height="100" alt="广告图片预览">
</div>
</form>易错点:隐藏域必须有 name="id",否则 FormData.get('id') 拿不到;添加时不能只靠 form.reset(),因为隐藏域的默认值可能仍是空,但显式写 document.addAdvForm.id.value = '' 更稳。
打开添加与修改时如何准备表单
打开修改模态框时,先根据按钮上的 data-id 调 getAdvById 拿到单条广告,再把 advTitle、_id、advType、advHref、orderNum 填回表单,并显示预览图:
js
const openEditModalExec = async event => {
if (event.target.classList.contains('btn-edit')) {
const {data} = await getAdvById(event.target.dataset.id);
document.addAdvForm.advTitle.value = data.advTitle;
document.addAdvForm.id.value = data._id;
document.addAdvForm.advType.value = data.advType;
document.addAdvForm.advHref.value = data.advHref;
document.addAdvForm.orderNum.value = data.orderNum;
preImgEle.style.display = 'block';
preImgEle.src = '/api/' + data.advPic;
document.querySelector('#advModal .modal-title').innerHTML = '修改广告';
$('#advModal').modal('show');
}
};打开添加模态框时则相反:重置表单、清空隐藏域、隐藏预览图、改标题为“添加广告”,再打开模态框。转写稿里调试时发现:如果先点修改再点添加,模态框会保留上一条修改数据,所以添加前必须清空。
提交时如何分流添加或修改
提交事件里先 event.preventDefault(),再用 new FormData(document.addAdvForm) 收集数据。关键代码如下:
js
const addAdvExec = async event => {
event.preventDefault();
const fd = new FormData(document.addAdvForm);
if (fd.get('id')) {
// 有 id:修改
await putAdv(fd);
toastr.success('修改成功!');
} else {
// 无 id:添加
fd.delete('id');
const fdArr = Array.from(fd);
if (!fdArr.every(item => item[1])) {
toastr.error('请将表单填写完整!');
return;
}
if (fd.get('advPic').size === 0) {
toastr.error('请选择要上传的图片!');
return;
}
await postAdv(fd);
toastr.success('添加成功!');
}
$('#advModal').modal('hide');
document.addAdvForm.reset();
preImgEle.style.display = 'none';
document.querySelector('#keyword').value = '';
getAdvExec();
};为什么添加时要 fd.delete('id')?因为隐藏域虽然为空,但 FormData 里仍可能带着空 id 字段,删除后再校验更干净。为什么用 Array.from(fd).every(item => item[1])?因为 FormData 不能直接 every,转成数组后逐项判断值是否为空。图片文件要单独判断 size === 0,空文件不是普通空字符串。
调试过程与易错点
转写稿里有一个典型调试:给搜索按钮直接绑定 getAdvExec 时,事件回调会把 event 传进第一个参数 no,导致请求参数 pageNo 变成事件对象。解决办法是套一层:addEventListener('click', () => getAdvExec()),不把事件对象当页码。分页和修改按钮因为列表会重新渲染,不能把事件绑在组件内部元素上,要委托到稳定的 #advTable 上,并判断 page-link、btn-edit。
图片预览路径也要注意:preImgEle.src = '/api/' + data.advPic;,因为图片在后端服务器目录,前端静态服务器没有该文件,必须借 /api 代理转发。添加成功后清空搜索框再刷新列表,否则旧关键字会把新数据过滤掉。修改成功不强制清空搜索框,是为了保持当前搜索上下文。
课堂画面





代码示例
示例一:获取广告列表 + 分页 + 搜索(API → 控制器 → 表格组件)
src/api/adv.js
js
// 导入实例
import advServer from '../request/advserver';
// 添加广告
export const postAdv = formdata => {
return advServer.post('/adv', formdata)
}
// 获取广告列表
export const getAdv = (pageNo, pageSize, keyword) => {
return advServer.get('/adv', {
params: {
pageNo,
pageSize,
keyword
}
})
}src/controllers/adv.js(列表 / 分页 / 搜索部分)
js
import toastr from 'toastr';
// 导入模板页面
import advV from '@/views/adv';
// 导入组件
import AdvTableComponent from '../components/AdvTable';
// 导入api函数
import { postAdv, getAdv } from '../api/adv';
// 创建变量 记录预览图元素
let preImgEle;
// 执行获取广告列表并渲染页面
const getAdvExec = async (no = 1, size = 3) => {
// 定义广告类型的描述 1:轮播图广告 2:轮播图底部广告 3:热门回收广告 4:优品精选广告
const advTypeDes = {
1: '轮播图广告',
2: '轮播图底部广告',
3: '热门回收广告',
4: '优品精选广告'
};
// 获取搜索框中的数据
const kw = document.querySelector('#keyword').value.trim();
// 调用后端API获取数据
const { data, pageNo, pageSum } = await getAdv(no, size, kw);
// 将数据传入组件
document.querySelector('#advTable').innerHTML = AdvTableComponent({
advList: data,
advTypeDes,
pageNo,
pageSum
});
};
// 执行分页渲染的函数
const setPageExec = event => {
// 判断是否是分页按钮
if (event.target.classList.contains('page-link')) {
getAdvExec(event.target.dataset.i);
}
};
export default (req, res) => {
// 渲染页面
res.render(advV());
// 获取广告列表并渲染到页面
getAdvExec();
// 点击分页按钮 事件委托
document.querySelector('#advTable').addEventListener('click', setPageExec);
// 点击搜索按钮
document.querySelector('#searchAdvBtn').addEventListener('click', () => {
getAdvExec();
});
}src/components/AdvTable.ejs
ejs
<div class="card-body table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>标题</th>
<th>图片</th>
<th>类别</th>
<th>链接</th>
<th>排序</th>
<th>添加时间</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<% data.advList.forEach(advItem => { %>
<tr>
<td><%= advItem.advTitle %></td>
<td><img height="80" src="/api/<%= advItem.advPic %>" alt=""></td>
<td><%= data.advTypeDes[advItem.advType] %></td>
<td><%= advItem.advHref %></td>
<td><%= advItem.orderNum %></td>
<td><%= advItem.addTime %></td>
<td><%= advItem.upTime %></td>
<td>
<button type="button" class="btn btn-danger btn-sm">删除</button>
<button type="button" data-id="<%= advItem._id %>" class="btn btn-success btn-sm btn-edit">修改</button>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="card-footer clearfix" id="pageDiv">
<ul class="pagination pagination-sm m-0 float-right">
<li class="page-item">
<a href="javascript:;" data-i="<%= data.pageNo - 1 %>" class="page-link">«</a>
</li>
<% for (let i = 1; i <= data.pageSum; i ++) { %>
<li class="page-item <%= data.pageNo === i ? 'active' : '' %>">
<a class="page-link" data-i="<%= i %>" href="javascript:;"><%= i %></a>
</li>
<% } %>
<li class="page-item">
<a href="javascript:;" data-i="<%= data.pageNo + 1 %>" class="page-link">»</a>
</li>
</ul>
</div>关键行与易错点
getAdv(no, size, kw)三个参数必须按位置直接传,不能传对象。写成getAdv({no, size})时,axios 的params会把对象序列化进 URL,pageNo变成[object Object],后端拿不到页码(转写稿里专门踩过这个坑)。- 形参默认值
(no = 1, size = 3)让“首次进入”和“点搜索”都从第 1 页开始;点分页时再显式传dataset.i。 data-i这种带连字符的属性,读取时要用event.target.dataset.i(驼峰),不能用dataset['data-i']。- 事件委托必须挂在
#advTable上,不能挂在组件内部元素上——每次getAdvExec都会把#advTable的innerHTML整体替换,组件内的监听器会被一起销毁。 - 页码高亮用
data.pageNo === i ? 'active' : '',注意===和三元运算符的?:不要写反。 - 图片地址必须拼
/api/前缀走代理,否则会去前端静态服务器找图片,全部 404。 - 类型列用
data.advTypeDes[advItem.advType]把数字 1~4 翻译成文字,advTypeDes从控制器传进组件,属性名 1/2/3/4 直接可用。 - 搜索关键字在
getAdvExec内部读取#keyword的值,这样翻页时也能带上关键字,搜索结果的分页才不会丢条件。
示例二:打开「添加」与「修改」模态框(共用一个模态框)
src/views/adv.ejs(关键部分:去掉 data-toggle,加隐藏域)
ejs
<div id="__sub-route-view">
<div class="card">
<div class="card-header">
<!-- 注意:这里不能再写 data-toggle="modal" data-target="#advModal" -->
<button type="button" id="addAdvBtn" class="btn btn-primary">添加广告</button>
<div class="card-tools">
<div class="input-group input-group-md">
<input type="text" class="form-control" id="keyword" placeholder="搜索关键字">
<div class="input-group-append">
<div class="btn btn-primary" id="searchAdvBtn">
<i class="fas fa-search"></i>
</div>
</div>
</div>
</div>
</div>
<div id="advTable"></div>
</div>
<div class="modal fade show" id="advModal" aria-modal="true" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">添加广告</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<form name="addAdvForm" class="form-horizontal" id="addAdvForm">
<!-- 隐藏域:有值=修改,无值=添加 -->
<input type="hidden" name="id" value="">
<div class="card-body">
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="advTitle">标题:</label>
<div class="col-sm-10">
<input id="advTitle" name="advTitle" type="text" class="form-control" placeholder="请输入广告标题">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="advType">类别:</label>
<div class="col-sm-10">
<select name="advType" id="advType" class="form-control">
<option value="1">轮播图广告</option>
<option value="2">轮播图底部广告</option>
<option value="3">热门回收广告</option>
<option value="4">优品精选广告</option>
</select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="advHref">链接:</label>
<div class="col-sm-10">
<input id="advHref" name="advHref" type="text" class="form-control" placeholder="请输入广告链接">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="orderNum">排序:</label>
<div class="col-sm-10">
<input id="orderNum" name="orderNum" type="text" class="form-control"
placeholder="请输入广告排序,数字越大越靠前">
</div>
</div>
<div class="form-group row">
<label class="col-sm-2 col-form-label" for="advPic">图片:</label>
<div class="col-sm-10 custom-file">
<input type="file" class="form-control" name="advPic" id="advPic">
</div>
</div>
<div>
<img style="display: none;" id="preImg" height="100">
</div>
</div>
</form>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" id="adv-cancel" data-dismiss="modal">取消</button>
<button type="submit" class="btn btn-primary" id="adv-save" form="addAdvForm">提交</button>
</div>
</div>
</div>
</div>
</div>src/controllers/adv.js(打开模态框部分)
js
import { postAdv, getAdv, getAdvById } from '../api/adv';
// 打开添加广告的模态框
const openAddModalExec = () => {
// 清空表单
document.addAdvForm.reset();
// 清空隐藏域
document.addAdvForm.id.value = '';
// 隐藏预览图
preImgEle.style.display = 'none';
// 修改标题
document.querySelector('#advModal .modal-title').innerHTML = '添加广告';
// 打开模态框
$('#advModal').modal('show');
};
// 打开修改广告的模态框 获取当前广告的详细信息
const openEditModalExec = async event => {
// 判断点击是修改按钮
if (event.target.classList.contains('btn-edit')) {
// 获取该条广告的信息
const { data } = await getAdvById(event.target.dataset.id);
// 将广告信息显示在表单里,作为表单控件的默认值
document.addAdvForm.advTitle.value = data.advTitle;
document.addAdvForm.id.value = data._id;
document.addAdvForm.advType.value = data.advType;
document.addAdvForm.advHref.value = data.advHref;
document.addAdvForm.orderNum.value = data.orderNum;
// 显示预览图
preImgEle.style.display = 'block';
preImgEle.src = '/api/' + data.advPic;
// 修改模态框标题
document.querySelector('#advModal .modal-title').innerHTML = '修改广告';
// 打开模态框
$('#advModal').modal('show');
}
};
export default (req, res) => {
res.render(advV());
getAdvExec();
// 点击添加广告的按钮
document.querySelector('#addAdvBtn').addEventListener('click', openAddModalExec);
// 点击表格中的修改按钮 事件委托
document.querySelector('#advTable').addEventListener('click', openEditModalExec);
// 获取预览图元素
preImgEle = document.querySelector('#preImg');
// 给图片上传input监听change事件,实现图片实时预览
document.querySelector('#advPic').addEventListener('change', prevImgExec);
}关键行与易错点
- 添加按钮上的
data-toggle="modal"和data-target="#advModal"必须删掉,否则 Bootstrap 会自己弹窗,跳过“清空表单 / 改标题 / 隐藏预览图”的逻辑,导致上次编辑的内容残留。 openEditModalExec的所有代码都要包在if (event.target.classList.contains('btn-edit'))里,因为委托目标是#advTable,点表格空白处也会触发。- 隐藏域
<input type="hidden" name="id" value="">是“添加/修改二合一”的开关,添加时openAddModalExec里必须显式置空。 - 预览图
preImgEle.src = '/api/' + data.advPic,/api/前缀不能少,否则图片仍走前端服务器 404。 advType是<select>,直接给.value赋数字字符串,浏览器会自动选中对应 option,不用手动改selected。- 图片
<input type="file">出于安全限制无法用 JS 预设值,修改时只能用#preImg把旧图展示出来。 - 提交按钮在
<form>外面,靠form="addAdvForm"属性关联,删掉这个属性提交事件就不会触发。
示例三:一次提交,按隐藏域 id 判断「添加」还是「修改」
src/api/adv.js(补充 putAdv)
js
// 修改广告(笔记中未展开实现,与 postAdv 同构)
export const putAdv = formdata => {
return advServer.put('/adv', formdata)
}
// 根据id获取广告信息
export const getAdvById = id => {
return advServer.get('/adv/' + id);
}src/controllers/adv.js(提交部分)
js
// 执行广告添加或修改
const addAdvExec = async event => {
// 阻止默认行为
event.preventDefault();
// 创建 FormData 对象,以 form 元素作为参数
const fd = new FormData(document.addAdvForm);
// 如果表单内容中有 id
if (fd.get('id')) {
// 执行修改
await putAdv(fd);
toastr.success('修改成功!');
} else {
// 执行添加
// 将 id 从 fd 中删除
fd.delete('id');
// 利用纯数组进行判断
const fdArr = Array.from(fd);
if (!fdArr.every(item => item[1])) {
toastr.error('请将表单填写完整!');
return;
}
if (fd.get('advPic').size === 0) {
toastr.error('请选择要上传的图片!');
return;
}
// 使用后端API添加广告
await postAdv(fd);
toastr.success('添加成功!');
}
// 模态框关闭
$('#advModal').modal('hide');
// 表单清空
document.addAdvForm.reset();
// 隐藏预览图
preImgEle.style.display = 'none';
// 清空搜索框
document.querySelector('#keyword').value = '';
// 更新列表
getAdvExec();
};
export default (req, res) => {
res.render(advV());
getAdvExec();
document.querySelector('#advTable').addEventListener('click', setPageExec);
document.querySelector('#searchAdvBtn').addEventListener('click', () => getAdvExec());
document.querySelector('#addAdvBtn').addEventListener('click', openAddModalExec);
document.querySelector('#advTable').addEventListener('click', openEditModalExec);
preImgEle = document.querySelector('#preImg');
document.querySelector('#advPic').addEventListener('change', prevImgExec);
// 给表单监听提交事件 添加或修改
document.addAdvForm.addEventListener('submit', addAdvExec);
}关键行与易错点
- 判断依据只有一条:
fd.get('id')有值就改,没值就加。别用模态框标题文字去判断,容易和 UI 文案耦合。 - 添加分支必须先
fd.delete('id'),否则空的id=""也会被fdArr.every(item => item[1])判成“没填完整”,永远提示表单不完整。 - 完整性校验和图片必填校验只放在添加分支里:修改时用户不重新选图,
advPic的size === 0,放外面会误报“请选择要上传的图片”。 new FormData(document.addAdvForm)会把隐藏域id一起收进去,这正是二合一方案能成立的前提。- 提交成功后要连做四件事:关模态框 →
reset()→ 隐藏预览图 → 清空#keyword→ 重新getAdvExec()。漏了清空搜索框,新添加的数据会因为还带着旧关键字而“看不见”。 event.preventDefault()不能省,否则表单会触发浏览器原生提交,页面直接跳走。
常见错误
1. 列表里的图片全部 404,控制台看不到任何接口请求
- 现象:
<img src="<%= advItem.advPic %>">渲染出的地址形如2023-xx/xxx.jpg,图片加载失败。 - 原因:相对路径被解析到前端静态服务器的当前路由下,而图片实际存在后端服务器的上传目录里,两个服务域名端口都不同。
- 修法:给路径补上代理前缀
/api/,让请求先打到前端代理服务器再转发到后端;不要只补一个/,那只解决了相对路径问题,仍指向前端服务器。
2. 点搜索按钮后列表乱掉,请求参数 pageNo 变成 [object Object]
- 现象:网络面板里
pageNo是[object Object],页面渲染异常或直接报错。 - 原因:
addEventListener('click', getAdvExec)把事件对象作为第一个实参传给了形参no。 - 修法:包一层回调
() => getAdvExec(),让函数按默认值no=1从第一页重新查。
3. 分页点第一次能翻,翻几次后再点就没反应
- 现象:首屏点击页码正常,翻过一两页后点击失效。
- 原因:事件委托绑在了
#advTable内部被整体替换的元素上(甚至绑在了组件内的#pageDiv),每次innerHTML重写后监听器随旧节点一起消失。 - 修法:把委托绑到不会被替换的稳定容器
#advTable上,在回调里用event.target.classList.contains('page-link')过滤目标。
4. 点“添加广告”时表单是空的却提示“请将表单填写完整”
- 现象:所有输入框都填了,仍弹出校验失败的 toastr。
- 原因:表单里有隐藏域
<input type="hidden" name="id" value="">,Array.from(fd)里多了一项空字符串,every(item => item[1])判假。 - 修法:新增分支里先
fd.delete('id')再做全量非空校验;或者校验时把id字段排除。
5. 修改广告时不换图片,提交后提示“请选择要上传的图片”
- 现象:只改标题或链接,提交被拦。
- 原因:把新增分支的
fd.get('advPic').size === 0校验误用到了修改分支,而修改时file输入为空。 - 修法:以
fd.get('id')是否有值为分支依据,修改走putAdv且跳过图片必填校验,由后端保留原图。
6. 先点“修改”再点“添加”,模态框里残留上一条数据
- 现象:打开添加模态框,标题、链接还是刚才那条广告的内容,预览图也没消失。
- 原因:复用一个模态框,回填的数据没有被重置,
reset()只清表单控件、不清<img>的src和display。 - 修法:在
openAddModalExec中依次做四件事——form.reset()、清空隐藏域id、preImgEle.style.display = 'none'、改回模态框标题;同时去掉按钮上的data-toggle/data-target,改为手动$('#advModal').modal('show')。
练习
- 删除广告:在
AdvTable的删除按钮上加data-id,用事件委托调用deleteAdv(id),成功后 toastr 提示并刷新列表;要求删除后若当前页已空,自动回退到上一页。 - 页码边界处理:第一页时“上一页”置灰不可点,最后一页时“下一页”置灰;不依赖后端兜底,纯前端用
pageNo与pageSum判断。 - 回车搜索:给
#keyword绑定keyup,按下 Enter 时触发搜索,且行为与点击放大镜按钮完全一致(从第一页开始查)。 - 每页条数切换:在表格上方加一个下拉框(3 / 5 / 10 条每页),切换后以新
size从第 1 页重新请求,并保证搜索关键字不丢失。 - 搜索无结果:关键字查不到数据时,表格区域显示一行“暂无匹配数据”的占位提示,分页区隐藏。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 获取广告列表后端API分析 | 12:45 |
| 2 | 获取广告列表 | 32:04 |
| 3 | 分页 | 20:00 |
| 4 | 搜索 | 13:11 |
| 5 | 打开修改广告和添加广告的模态框 | 30:05 |
小结
- 图片等静态资源跨服务访问时,路径前缀决定请求打到哪台服务器;用代理前缀
/api/既解决地址问题,也天然规避跨域。 - 分页的本质是把
pageNo、pageSize作为请求参数下发给后端,前端只负责渲染后端返回的pageNo与pageSum,不在前端算总页数。 - 搜索关键字应在“发起请求的函数内部”统一读取,而不是在点击回调里读,这样翻页时会自动携带当前关键字,搜索与分页才能协同工作。
- 凡是内容会被
innerHTML整体替换的容器,其内部元素都不能作为事件委托的宿主,必须上提到稳定的父级。 - 事件监听回调不要直接写目标函数名,除非你确认它不接收参数——
addEventListener会把事件对象塞进第一个形参。 - 添加与修改复用同一个模态框、同一份表单时,用隐藏域
id的有无作为唯一分支依据,能同时解决“提交走哪个接口”和“是否校验图片”两个问题。 - 复用模态框必须做到“打开即重置”:表单控件、隐藏域、预览图、标题四项缺一不可,否则上一条数据会污染下一次操作。
- 新增成功后清空搜索框,是为了避免用户以为新数据没添加成功——列表此时仍被旧关键字过滤着。