Skip to content

获取广告列表与分页(功能完善与部署 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 代理转发。添加成功后清空搜索框再刷新列表,否则旧关键字会把新数据过滤掉。修改成功不强制清空搜索框,是为了保持当前搜索上下文。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例一:获取广告列表 + 分页 + 搜索(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')。

练习 ​

  1. 删除广告:在 AdvTable 的删除按钮上加 data-id,用事件委托调用 deleteAdv(id),成功后 toastr 提示并刷新列表;要求删除后若当前页已空,自动回退到上一页。
  2. 页码边界处理:第一页时“上一页”置灰不可点,最后一页时“下一页”置灰;不依赖后端兜底,纯前端用 pageNo 与 pageSum 判断。
  3. 回车搜索:给 #keyword 绑定 keyup,按下 Enter 时触发搜索,且行为与点击放大镜按钮完全一致(从第一页开始查)。
  4. 每页条数切换:在表格上方加一个下拉框(3 / 5 / 10 条每页),切换后以新 size 从第 1 页重新请求,并保证搜索关键字不丢失。
  5. 搜索无结果:关键字查不到数据时,表格区域显示一行“暂无匹配数据”的占位提示,分页区隐藏。

原视频对照 ​

#视频时长
1获取广告列表后端API分析12:45
2获取广告列表32:04
3分页20:00
4搜索13:11
5打开修改广告和添加广告的模态框30:05

小结 ​

  1. 图片等静态资源跨服务访问时,路径前缀决定请求打到哪台服务器;用代理前缀 /api/ 既解决地址问题,也天然规避跨域。
  2. 分页的本质是把 pageNo、pageSize 作为请求参数下发给后端,前端只负责渲染后端返回的 pageNo 与 pageSum,不在前端算总页数。
  3. 搜索关键字应在“发起请求的函数内部”统一读取,而不是在点击回调里读,这样翻页时会自动携带当前关键字,搜索与分页才能协同工作。
  4. 凡是内容会被 innerHTML 整体替换的容器,其内部元素都不能作为事件委托的宿主,必须上提到稳定的父级。
  5. 事件监听回调不要直接写目标函数名,除非你确认它不接收参数——addEventListener 会把事件对象塞进第一个形参。
  6. 添加与修改复用同一个模态框、同一份表单时,用隐藏域 id 的有无作为唯一分支依据,能同时解决“提交走哪个接口”和“是否校验图片”两个问题。
  7. 复用模态框必须做到“打开即重置”:表单控件、隐藏域、预览图、标题四项缺一不可,否则上一条数据会污染下一次操作。
  8. 新增成功后清空搜索框,是为了避免用户以为新数据没添加成功——列表此时仍被旧关键字过滤着。