05 黑马头条 数据管理平台
项目介绍
介绍我们要做的项目,为何做,以及怎么做
黑马头条 - 数据管理平台:对 IT 资讯移动网站的数据,进行数据管理
数据管理平台 - 演示:配套代码在本地运行
移动网站 - 演示: 极客园

功能:
- 登录和权限判断
- 查看文章内容列表(筛选,分页)
- 编辑文章(数据回显)
- 删除文章
- 发布文章(图片上传,富文本编辑器)
文档:
小结
黑马头条 - 数据管理平台,是什么样网站,要完成哪些功能?
- 数据管理网站,登录后对数据进行增删改查
数据管理平台,未登录能否管理数据?
- 不能,数据是公司内部的,需账号登录后管理
项目准备
了解项目需要准备哪些内容
技术:
- 基于 Bootstrap 搭建网站标签和样式
- 集成 wangEditor 插件实现富文本编辑器
- 使用原生 JS 完成增删改查等业务
- 基于 axios 与黑马头条线上接口交互
- 使用 axios 拦截器进行权限判断
项目准备:准备配套的素材代码
- 包含:html,css,js,静态图片,第三方插件等等
目录管理:建议这样管理,方便查找
assets:资源文件夹(图片,字体等)lib:资料文件夹(第三方插件,例如:form-serialize)page:页面文件夹utils:实用程序文件夹(工具插件)
项目目录
.
├── assets
├── lib
│ └── form-serialize.js
├── page
│ ├── content
│ │ ├── index.css
│ │ ├── index.html
│ │ └── index.js
│ ├── login
│ └── publish
└── utils登录退出
- 完成验证码登录,后端设置验证码默认为 246810
- 完成退出登录效果
验证码登录
在
utils/request.js配置 axios 请求基地址作用:提取公共前缀地址,配置后 axios 请求时都会
baseURL+urljsaxios.defaults.baseURL = 'http://geek.itheima.net';收集手机号和验证码数据
基于 axios 调用验证码登录接口
使用 Bootstrap 的 Alert 警告框反馈结果给用户

验证码登录流程
了解验证码登录的流程
手机号 + 验证码,登录流程:

token 的介绍
了解前后端分离项目中 token 的作用
概念:访问权限的令牌,本质上是一串字符串
创建:正确登录后,由后端签发并返回
作用:判断是否有登录状态等,控制访问权限
注意:前端只能判断 token 有无,而后端才能判断 token 的有效性

目标:只有登录状态,才可以访问内容页面
步骤:
- 在
utils/auth.js中判断无 token 令牌字符串,则强制跳转到登录页(手动修改地址栏测试) - 在登录成功后,保存 token 令牌字符串到本地,再跳转到首页(手动修改地址栏测试)
jsconst token = localStorage.getItem('token'); // 没有 token 令牌字符串,则强制跳转登录页 if (!token) { location.href = '../login/index.html'; }- 在
小结
token 的作用?
- 判断用户是否有登录状态等
token 的注意:
- 前端只能判断 token 的有无
- 后端通过解密可以提取 token 字符串的原始信息,判断有效性
个人信息设置和 axios 请求拦截器
了解 axios 请求拦截器的概念和使用场景
- 需求:设置用户昵称
- 语法:axios 可以在 headers 选项传递请求头参数
- 问题:很多接口,都需要携带 token 令牌字符串
- 解决:在 请求拦截器 统一设置公共 headers 选项


// 文档:https://www.axios-http.cn/docs/interceptors
// 添加请求拦截器
axios.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
// 从本地存储中获取 token 令牌字符串
const token = localStorage.getItem('token');
// 统一携带 token 令牌字符串在请求头上
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// 对请求错误做些什么
// 创建了一个新的 Promise 对象,并将其状态设置为 rejected(失败)
// 在后续使用时,可以通过 .catch() 方法拿到错误信息
return Promise.reject(error);
},
);小结
什么是 axios 请求拦截器?
- 发起请求之前,调用的一个函数,对请求参数进行设置
axios 请求拦截器,什么时候使用?
- 有公共配置和设置时,统一设置在请求拦截器中
axios 响应拦截器和身份验证失败
了解 axios 响应拦截器的概念和使用场景,以及身份验证失败的场景流程和判断使用
- axios 响应拦截器:响应回到
then/catch之前,触发的拦截函数,对响应结果统一处理 - 例如:身份验证失败,统一判断并做处理

// 添加响应拦截器
axios.interceptors.response.use(
(response) => {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么,例如:直接返回服务器的响应结果对象
// 优化 axios 响应结果:可以让逻辑页面少点一层 data 就能拿到后端返回的真正数据对象
return response.data;
},
(error) => {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么,例如:统一对 401 身份验证失败情况做出处理
// console.dir(error);
return Promise.reject(error);
},
);小结
什么是 axios 响应拦截器?
- 响应回到
then/catch之前,触发的拦截函数,对响应结果统一处理
- 响应回到
axios 响应拦截器,什么时候触发成功/失败的回调函数?
- 状态为
2xx触发成功回调,其他则触发失败的回调函数
- 状态为
优化 axios 响应结果
axios 直接接收服务器返回的响应结果
- 思路:其实就是在响应拦截器里,
response.data把后台返回的数据直接取出来统一返回给所有使用这个 axios 函数的逻辑页面位置的then的形参上 - 好处:可以让逻辑页面少点一层
data就能拿到后端返回的真正数据对象

axios.interceptors.response.use(
function (response) {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么,例如:直接返回服务器的响应结果对象
const result = response.data;
return result;
},
function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么,例如:判断响应状态为 401 代表身份验证失败
if (error?.response?.status === 401) {
alert('登录状态过期,请重新登录');
window.location.href = '../login/index.html';
}
return Promise.reject(error);
},
);退出登录
完成退出登录效果
步骤:
- 绑定点击事件
- 清空本地缓存,跳转到登录页面

相关代码
utils/auth.js:权限插件(引入到了除登录页面,以外的其他所有页面)访问权限控制
- 判断无
token令牌字符串,则强制跳转到登录页 - 登录成功后,保存
token令牌字符串到本地,并跳转到内容列表页面
- 判断无
设置个人信息
- 在
utils/request.js设置请求拦截器,统一携带token - 请求个人信息并设置到页面
- 在
退出登录
- 绑定点击事件
- 清空本地缓存,跳转到登录页面
utils/request.js:axios 请求封装- 配置
baseURL基地址 - 配置请求拦截器,从本地存储中获取 token 令牌字符串,统一携带
token令牌字符串在请求头上 - 配置响应拦截器,优化 axios 响应结果,统一处理
401状态码
- 配置
page/login/index.html:验证码登录- 使用 serialize 收集手机号和验证码数据,前提每个表单元素都要有 name 属性
- 基于 axios 调用验证码登录接口
- 使用 Bootstrap 的 Alert 警告框反馈结果给用户 (成功/失败)
- 保存 token 令牌字符串到本地
- 延时 1.5s 跳转到内容列表页面,让用户看到登录成功的提示
// 权限插件(引入到了除登录页面,以外的其他所有页面)
/**
* 目标 1:访问权限控制
* 1.1 判断无 token 令牌字符串,则强制跳转到登录页
* 1.2 登录成功后,保存 token 令牌字符串到本地,并跳转到内容列表页面
*/
const token = localStorage.getItem('token');
if (!token && location.pathname !== '/content/index.html') {
// 无 token 令牌字符串,则强制跳转到登录页
location.href = '../login/index.html';
}
/**
* 目标 2:设置个人信息
* 2.1 在 utils/request.js 设置请求拦截器,统一携带 token
* 2.2 请求个人信息并设置到页面
*/
axios.get('/v1_0/user/profile').then((res) => {
console.log(res);
// $('.nick-name').html(res.data.name); // jQuery 语法
document.querySelector('.nick-name').innerHTML = res.data.name;
});
/**
* 目标 3:退出登录
* 3.1 绑定点击事件
* 3.2 清空本地缓存,跳转到登录页面
*/
quit = document.querySelector('.quit');
quit.addEventListener('click', () => {
localStorage.removeItem('token');
location.href = '../login/index.html';
});// axios 公共配置
// 基地址
// 文档:https://www.axios-http.cn/docs/config_defaults#全局-axios-默认值
axios.defaults.baseURL = 'https://geek.itheima.net';
// 文档:https://www.axios-http.cn/docs/interceptors
// 添加请求拦截器
axios.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
// 从本地存储中获取 token 令牌字符串
const token = localStorage.getItem('token');
// 统一携带 token 令牌字符串在请求头上
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// 对请求错误做些什么
// 创建了一个新的 Promise 对象,并将其状态设置为 rejected(失败)
// 在后续使用时,可以通过 .catch() 方法拿到错误信息
return Promise.reject(error);
},
);
// 添加响应拦截器
axios.interceptors.response.use(
(response) => {
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么,例如:直接返回服务器的响应结果对象
// 优化 axios 响应结果:可以让逻辑页面少点一层 data 就能拿到后端返回的真正数据对象
return response.data;
},
(error) => {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么,例如:统一对 401 身份验证失败情况做出处理
// console.dir(error);
if (error?.response?.status === 401) {
// error?.response?.status 为 es6 可选链语法:防止属性不存在报错
console.log(error?.response?.data);
// 401 身份验证失败
alert('身份验证失败,请重新登录!');
// 清除无效 token
localStorage.removeItem('token');
// 跳转到登录页面
location.href = '../login/index.html';
}
return Promise.reject(error);
},
);/**
* 目标:验证码登录
* - 收集手机号和验证码数据
* - 基于 axios 调用验证码登录接口
* - 使用 Bootstrap 的 Alert 警告框反馈结果给用户
*/
async function codeLogin() {
try {
// 使用 serialize 收集表单数据,前提每个表单元素都要有 name 属性
const form = document.querySelector('.login-form');
const formData = serialize(form, { hash: true, empty: true });
// 基于 axios 调用验证码登录接口
const res = await axios.post('/v1_0/authorizations', formData);
console.log(res);
// 使用 Bootstrap 的 Alert 警告框反馈结果给用户
myAlert(true, '登录成功!');
// 保存 token 令牌字符串到本地
localStorage.setItem('token', res.data.token);
// 延时 1.5s 跳转到内容列表页面,让用户看到登录成功的提示
setTimeout(() => {
location.href = '../content/index.html';
}, 1500);
} catch (err) {
console.dir(err);
// 使用 Bootstrap 的 Alert 警告框反馈结果给用户
// myAlert(false, '登录失败!');
myAlert(false, err.response.data.message);
console.log(err.response.data.message);
}
}
// 监听表单提交事件
document.querySelector('.btn').addEventListener('click', codeLogin);发布文章
富文本编辑器
了解富文本编辑器的概念,以及如何在前端网页中使用
- 富文本:带样式,多格式的文本,在前端一般使用标签配合内联样式实现
- 富文本编辑器:用于编写富文本内容的容器

目标:发布文章页,富文本编辑器的集成
使用:wangEditor 插件
步骤:参考文档
引入 CSS 定义样式
html<link href="https://unpkg.com/@wangeditor/editor@latest/dist/css/style.css" rel="stylesheet" /> <style> #editor—wrapper { border: 1px solid #ccc; z-index: 100; /* 按需定义 */ } #toolbar-container { border-bottom: 1px solid #ccc; } #editor-container { height: 500px; } </style>定义 HTML 结构
html<div id="editor—wrapper"> <div id="toolbar-container"><!-- 工具栏 --></div> <div id="editor-container"><!-- 编辑器 --></div> </div>引入 JS 创建编辑器
html<script src="https://unpkg.com/@wangeditor/editor@latest/dist/index.js"></script> <script> const { createEditor, createToolbar } = window.wangEditor; const editorConfig = { placeholder: 'Type here...', onChange(editor) { const html = editor.getHtml(); console.log('editor content', html); // 也可以同步到 <textarea> }, }; const editor = createEditor({ selector: '#editor-container', html: '<p><br></p>', config: editorConfig, mode: 'default', // or 'simple' }); const toolbarConfig = {}; const toolbar = createToolbar({ editor, selector: '#toolbar-container', config: toolbarConfig, mode: 'default', // or 'simple' }); </script>监听内容改变,保存在隐藏文本域(便于后期收集)
jsconst editorConfig = { placeholder: 'Type here...', // 编辑器的占位符文本 onChange(editor) { // 监听编辑器内容变化,当编辑器的内容发生变化时,这个函数会被调用 // 使用 editor.getHtml() 获取了编辑器的 HTML 内容,并将其打印到控制台。 const html = editor.getHtml(); console.log('editor content', html); // 也可以同步到 <textarea>: 为了后续快速收集整个表单内容做铺垫 document.querySelector('textarea.publish-content').value = html; }, };
频道列表
展示频道列表,供用户选择
步骤:
- 获取频道列表数据
- 展示到下拉菜单中
const channelId = document.querySelector('#channel_id');
async function renderChannels() {
try {
const res = await axios.get('/v1_0/channels');
console.log(res);
channelId.innerHTML = res.data.channels
.map((item) => {
return `<option value="${item.id}">${item.name}</option>`;
})
.join('');
} catch (error) {
console.error(error);
}
}
renderChannels();
封面设置
文章封面的设置
步骤:
- 准备标签结构和样式
- 选择文件并保存在 FormData
- 单独上传图片并得到图片 URL 地址
- 回显并切换
img标签展示(隐藏 + 号上传标签)
注意:图片地址临时存储在 img 标签上,并未和文章关联保存
const imgFile = document.querySelector('.img-file');
const rounded = document.querySelector('.rounded');
const place = document.querySelector('.place');
function setCover() {
imgFile.addEventListener('change', async function () {
const formData = new FormData();
formData.append('image', this.files[0]);
try {
const res = await axios.post('/v1_0/upload', formData);
console.log(res);
// 回显图片
rounded.src = res.data.url;
rounded.classList.add('show');
// 隐藏上传按钮
place.classList.add('hide');
} catch (error) {
console.error(error);
}
});
// 点击图片,可以重新上传用于切换文章封面
rounded.addEventListener('click', () => {
imgFile.click();
});
}
setCover();
收集并保存
收集文章内容,并提交保存
步骤:
- 基于 form-serialize 插件收集表单数据对象
- 基于 axios 提交到服务器保存
- 调用 Alert 警告框反馈结果给用户
- 重置表单并跳转到列表页
const sendBtn = document.querySelector('button.send');
const form = document.querySelector('.art-form');
async function publishArticle() {
sendBtn.addEventListener('click', async (e) => {
if (e.target.innerHTML !== '发布') return;
const formData = serialize(form, { hash: true, empty: true });
console.log(formData); // channel_id, content, id, title
// formData 中多了 id 属性,少了 cover(type, images) 属性
formData.id = undefined;
formData.cover = {
type: 1, // 文章封面类型,默认传递 1-1 张图
images: [rounded.src], // 文章封面地址数组
};
try {
const res = await axios.post('/v1_0/mp/articles', formData);
console.log(res);
myAlert(true, `发布成功 ~ ${res.message}`);
// 重置表格,封面,富文本编辑器和 textarea
form.reset();
rounded.src = '';
rounded.classList.remove('show');
place.classList.remove('hide');
editor.setHtml('');
document.querySelector('textarea.publish-content').value = '';
// 1.5s 后跳转到内容管理页面
setTimeout(() => {
window.location.href = '../content/index.html';
}, 1500);
} catch (error) {
console.dir(error);
myAlert(false, `发布失败 ~ ${error.response.data.message}`);
}
});
}
publishArticle();
相关代码
// 富文本编辑器
// 创建编辑器函数,创建工具栏函数
// 从 window.wangEditor 对象中解构出 createEditor 和 createToolbar 方法
// createEditor 创建编辑器
// createToolbar 创建工具栏
const { createEditor, createToolbar } = window.wangEditor;
const editorConfig = {
placeholder: 'Type here...', // 编辑器的占位符文本
onChange(editor) {
// 监听编辑器内容变化,当编辑器的内容发生变化时,这个函数会被调用
// 使用 editor.getHtml() 获取了编辑器的 HTML 内容,并将其打印到控制台。
const html = editor.getHtml();
console.log('editor content', html);
// 也可以同步到 <textarea>: 为了后续快速收集整个表单内容做铺垫
document.querySelector('textarea.publish-content').value = html;
},
};
const editor = createEditor({
selector: '#editor-container', // 编辑器容器的选择器
html: '<p><br></p>', // 编辑器的初始 HTML 内容
config: editorConfig, // 编辑器的配置对象
mode: 'default', // 编辑器的模式 'simple' or 'default'
});
const toolbarConfig = {}; // 配置工具栏
// 创建了工具栏
const toolbar = createToolbar({
editor, // 关联的编辑器
selector: '#toolbar-container', // 工具栏容器的选择器
config: toolbarConfig, // 工具栏的配置对象
mode: 'default', // 工具栏的模式 'simple' or 'default'
});/**
* 目标 1:设置频道下拉菜单
* 1.1 获取频道列表数据
* 1.2 展示到下拉菜单中
*/
const channelId = document.querySelector('#channel_id');
async function renderChannels() {
try {
const res = await axios.get('/v1_0/channels');
console.log(res);
channelId.innerHTML = res.data.channels
.map((item) => {
return `<option value="${item.id}">${item.name}</option>`;
})
.join('');
} catch (error) {
console.error(error);
}
}
renderChannels();
/**
* 目标 2:文章封面设置
* 2.1 准备标签结构和样式
* 2.2 选择文件并保存在 FormData
* 2.3 单独上传图片并得到图片 URL 网址
* 2.4 回显并切换 img 标签展示(隐藏 + 号上传标签)
*/
const imgFile = document.querySelector('.img-file');
const rounded = document.querySelector('.rounded');
const place = document.querySelector('.place');
function setCover() {
imgFile.addEventListener('change', async function () {
const formData = new FormData();
formData.append('image', this.files[0]);
try {
const res = await axios.post('/v1_0/upload', formData);
console.log(res);
// 回显图片
rounded.src = res.data.url;
rounded.classList.add('show');
// 隐藏上传按钮
place.classList.add('hide');
} catch (error) {
console.error(error);
}
});
// 点击图片,可以重新上传用于切换文章封面
rounded.addEventListener('click', () => {
imgFile.click();
});
}
setCover();
/**
* 目标 3:发布文章保存
* 3.1 基于 form-serialize 插件收集表单数据对象
* 3.2 基于 axios 提交到服务器保存
* 3.3 调用 Alert 警告框反馈结果给用户
* 3.4 重置表单并跳转到列表页
*/
const sendBtn = document.querySelector('button.send');
const form = document.querySelector('.art-form');
async function publishArticle() {
sendBtn.addEventListener('click', async (e) => {
if (e.target.innerHTML !== '发布') return;
const formData = serialize(form, { hash: true, empty: true });
console.log(formData); // channel_id, content, id, title
// formData 中多了 id 属性,少了 cover(type, images) 属性
formData.id = undefined;
formData.cover = {
type: 1, // 文章封面类型,默认传递 1-1 张图
images: [rounded.src], // 文章封面地址数组
};
try {
const res = await axios.post('/v1_0/mp/articles', formData);
console.log(res);
myAlert(true, `发布成功 ~ ${res.message}`);
// 重置表格,封面,富文本编辑器和 textarea
form.reset();
rounded.src = '';
rounded.classList.remove('show');
place.classList.remove('hide');
editor.setHtml('');
document.querySelector('textarea.publish-content').value = '';
// 1.5s 后跳转到内容管理页面
setTimeout(() => {
window.location.href = '../content/index.html';
}, 1500);
} catch (error) {
console.dir(error);
myAlert(false, `发布失败 ~ ${error.response.data.message}`);
}
});
}
publishArticle();内容管理
文章列表展示
获取文章列表并展示
步骤:
- 准备查询参数对象
- 获取文章列表数据
- 展示到指定的标签结构中

const artList = document.querySelector('.align-middle.art-list');
let nowPage = '1';
let totalPage;
// 准备查询参数对象
const params = {
// 文章状态:1-待审核,2-审核通过,不传为全部
status: '',
// 频道 id,不传为全部,可选
channel_id: '',
// 当前页码,可选
page: nowPage,
// 每页条数,可选
per_page: 2,
};
async function renderArticles() {
try {
const paramsObj = new URLSearchParams(params);
const queryParam = paramsObj.toString();
// console.log(queryParam);
const res = await axios.get(`/v1_0/mp/articles?${queryParam}`);
console.log(res);
const defaultCover = 'https://img2.baidu.com/it/u=2640406343,1419332367&fm=253&fmt=auto&app=138&f=JPEG?w=708&h=500';
artList.innerHTML = res.data.results
.map((item) => {
return `
<tr>
<td><img src="${
item.cover.type === 0 ? defaultCover : item.cover.images[0]
}" alt="" referrerpolicy="no-referrer"></td>
<td>${item.title}</td>
<td>${
item.status === 2
? `<span class="badge text-bg-success">审核通过</span>`
: `<span class="badge text-bg-primary">待审核</span>`
}</td>
<td><span>${item.pubdate}</span></td>
<td><span>${item.read_count}</span></td>
<td><span>${item.comment_count}</span></td>
<td><span>${item.like_count}</span></td>
<td data-id="${item.id}">
<i class="bi bi-pencil-square edit"></i>
<i class="bi bi-trash3 del"></i>
</td>
</tr>`;
})
.join('');
// 设置页面总条数
totalPage = Math.ceil(res.data.total_count / params.per_page); // 向上取整
document.querySelector('.total-count.page-now').innerHTML = `共 ${totalPage} 页`;
// 设置当前页码
document.querySelector('.page-item.page-now').innerHTML = `第 ${nowPage} 页`;
} catch (error) {
console.dir(error);
}
}
renderArticles();筛选功能
根据筛选条件,获取匹配数据展示
步骤:
- 设置频道列表数据
- 监听筛选条件改变,保存查询信息到查询参数对象
- 点击筛选时,传递查询参数对象到服务器
- 获取匹配数据,覆盖到页面展示
const channelId = document.querySelector('select.form-select');
// 渲染频道列表数据
async function renderChannels() {
try {
const res = await axios.get('/v1_0/channels');
console.log(res);
channelId.innerHTML = res.data.channels
.map((item) => {
return `<option value="${item.id}">${item.name}</option>`;
})
.join('');
} catch (error) {
console.error(error);
}
}
renderChannels();
// 获取选择的审核状态
for (const radio of document.querySelectorAll('.form-check-input')) {
radio.addEventListener('change', async function (e) {
params.status = this.value;
});
}
// 获取选择的频道 id
channelId.addEventListener('change', async function (e) {
params.channel_id = this.value;
});
// 筛选按钮绑定事件
const selBtn = document.querySelector('button.sel-btn');
selBtn.addEventListener('click', async () => {
renderArticles();
});
分页功能
完成文章列表,分页管理功能
步骤:
- 保存并设置文章总条数
- 点击下一页,做临界值判断,并切换页码参数请求最新数据
- 点击上一页,做临界值判断,并切换页码参数请求最新数据
const lastBtn = document.querySelector('.last');
const nextBtn = document.querySelector('.next');
lastBtn.addEventListener('click', async (e) => {
nowPage > 1
? nowPage-- && (await renderArticles())
: myAlert(false, '已经是第一页了') && console.log('已经是第一页了');
});
nextBtn.addEventListener('click', async (e) => {
nowPage < totalPage
? nowPage++ && (await renderArticles())
: myAlert(false, '已经是最后一页了') && console.log('已经是最后一页了');
});
删除功能
完成删除文章功能
步骤:
- 关联文章 id 到删除图标
- 点击删除时,获取文章 id
- 调用删除接口,传递文章 id 到服务器
- 重新获取文章列表,并覆盖展示
async function delArticle() {
artList.addEventListener('click', async (e) => {
const delBtn = e.target.classList.contains('del');
// console.log(delBtn);
if (delBtn) {
const id = e.target.parentNode.getAttribute('data-id');
try {
const res = await axios.delete(`/v1_0/mp/articles/${id}`);
// console.log(res);
// 判断是否是当前页面列表里仅有的一条
} catch (error) {
console.error(error);
}
}
});
}
delArticle();
删除最后一条
在删除最后一页,最后一条时有 Bug
步骤:
- 删除成功时,判断 DOM 元素只剩一条,让当前页码 page--
- 注意,当前页码为 1 时不能继续向前翻页
- 重新设置页码数,获取最新列表展示
// 如果删除的是当前页面的最后一条,需要自动向前翻页
const articleNum = artList.children.length;
if (articleNum === 1 && nowPage > 1) {
nowPage--;
await renderArticles();
}
编辑文章 回显
编辑文章时,回显数据到表单
步骤:
- 页面跳转传参(URL 查询参数方式)
- 发布文章页面接收参数判断(共用同一套表单)
- 修改标题和按钮文字
- 获取文章详情数据并回显表单

artList.addEventListener('click', (e) => {
const editBtn = e.target.classList.contains('edit');
console.log(editBtn);
if (editBtn) {
const id = e.target.parentNode.getAttribute('data-id');
location.href = `../publish/index.html?id=${id}`;
}
});
(async () => {
// 发布文章页面接收参数判断(共用同一套表单)
const params = new URLSearchParams(location.search);
for (const [key, value] of params) {
// 当前有要编辑的文章 id 被传入过来
if (key === 'id') {
// 修改标题和按钮文字
document.querySelector('.title span').innerHTML = '修改文章';
sendBtn.innerHTML = '修改';
// 获取文章详情数据并回显表单
const res = await axios.get(`/v1_0/mp/articles/${value}`);
console.log(res);
// 准备数据对象
const dataObj = {
channel_id: res.data.channel_id,
title: res.data.title,
rounded: res.data.cover.images[0],
content: res.data.content,
id: res.data.id,
};
// 将数据插入到页面元素中:遍历数据对象属性,映射到页面元素上,快速赋值
for (const key of Object.keys(dataObj)) {
if (key === 'rounded') {
// 封面设置
if (dataObj[key]) {
rounded.src = dataObj[key];
rounded.classList.add('show');
place.classList.add('hide');
}
} else if (key === 'content') {
// 富文本编辑器内容设置
editor.setHtml(dataObj[key]);
} else {
// 其他表单元素:频道,标题,id
// 用数据对象属性名,作为标签 name 属性选择器值来找到匹配的标签
document.querySelector(`[name=${key}]`).value = dataObj[key];
}
}
}
}
})();编辑文章 保存
确认修改,保存文章到服务器
步骤:
- 判断按钮文字,区分业务(因为共用一套表单)
- 调用编辑文章接口,保存信息到服务器
- 基于 Alert 反馈结果消息给用户
sendBtn.addEventListener('click', async (e) => {
if (e.target.innerHTML !== '修改') return;
const formData = serialize(form, { hash: true, empty: true });
console.log(formData); // channel_id, content, id, title
// formData 中少了 cover(type, images) 属性
formData.cover = {
type: rounded.src ? 1 : 0, // 文章封面类型,默认传递 1-1 张图
images: [rounded.src], // 文章封面地址数组
};
try {
const res = await axios.put(`/v1_0/mp/articles/${formData.id}`, formData);
console.log(res);
myAlert(true, `修改成功 ~ ${res.message}`);
// 1.5s 后跳转到内容管理页面
setTimeout(() => {
window.location.href = '../content/index.html';
}, 1500);
} catch (error) {
console.dir(error);
myAlert(false, `修改失败 ~ ${error.response.data.message}`);
}
});
相关代码
/**
* 目标 1:获取文章列表并展示
* 1.1 准备查询参数对象
* 1.2 获取文章列表数据
* 1.3 展示到指定的标签结构中
*/
const artList = document.querySelector('.align-middle.art-list');
let nowPage = '1';
let totalPage;
// 准备查询参数对象
const params = {
// 文章状态:1-待审核,2-审核通过,不传为全部
status: '',
// 频道 id,不传为全部,可选
channel_id: '',
// 当前页码,可选
page: nowPage,
// 每页条数,可选
per_page: 2,
};
async function renderArticles() {
try {
const paramsObj = new URLSearchParams(params);
const queryParam = paramsObj.toString();
// console.log(queryParam);
const res = await axios.get(`/v1_0/mp/articles?${queryParam}`);
console.log(res);
const defaultCover = 'https://img2.baidu.com/it/u=2640406343,1419332367&fm=253&fmt=auto&app=138&f=JPEG?w=708&h=500';
artList.innerHTML = res.data.results
.map((item) => {
return `
<tr>
<td><img src="${item.cover.type === 0 ? defaultCover : item.cover.images[0]}" alt="" referrerpolicy="no-referrer"></td>
<td>${item.title}</td>
<td>${
item.status === 2
? `<span class="badge text-bg-success">审核通过</span>`
: `<span class="badge text-bg-primary">待审核</span>`
}</td>
<td><span>${item.pubdate}</span></td>
<td><span>${item.read_count}</span></td>
<td><span>${item.comment_count}</span></td>
<td><span>${item.like_count}</span></td>
<td data-id="${item.id}">
<i class="bi bi-pencil-square edit"></i>
<i class="bi bi-trash3 del"></i>
</td>
</tr>`;
})
.join('');
// 设置页面总条数
totalPage = Math.ceil(res.data.total_count / params.per_page); // 向上取整
document.querySelector('.total-count.page-now').innerHTML = `共 ${totalPage} 页`;
// 设置当前页码
document.querySelector('.page-item.page-now').innerHTML = `第 ${nowPage} 页`;
} catch (error) {
console.dir(error);
}
}
renderArticles();
/**
* 目标 2:筛选文章列表
* 2.1 设置频道列表数据
* 2.2 监听筛选条件改变,保存查询信息到查询参数对象
* 2.3 点击筛选时,传递查询参数对象到服务器
* 2.4 获取匹配数据,覆盖到页面展示
*/
const channelId = document.querySelector('select.form-select');
// 渲染频道列表数据
async function renderChannels() {
try {
const res = await axios.get('/v1_0/channels');
console.log(res);
channelId.innerHTML = res.data.channels
.map((item) => {
return `<option value="${item.id}">${item.name}</option>`;
})
.join('');
} catch (error) {
console.error(error);
}
}
renderChannels();
// 获取选择的审核状态
for (const radio of document.querySelectorAll('.form-check-input')) {
radio.addEventListener('change', async function (e) {
params.status = this.value;
});
}
// 获取选择的频道 id
channelId.addEventListener('change', async function (e) {
params.channel_id = this.value;
});
// 筛选按钮绑定事件
const selBtn = document.querySelector('button.sel-btn');
selBtn.addEventListener('click', async () => {
renderArticles();
});
/**
* 目标 3:分页功能
* 3.1 保存并设置文章总条数
* 3.2 点击下一页,做临界值判断,并切换页码参数并请求最新数据
* 3.3 点击上一页,做临界值判断,并切换页码参数并请求最新数据
*/
const lastBtn = document.querySelector('.last');
const nextBtn = document.querySelector('.next');
lastBtn.addEventListener('click', async (e) => {
nowPage > 1
? nowPage-- && (await renderArticles())
: myAlert(false, '已经是第一页了') && console.log('已经是第一页了');
});
nextBtn.addEventListener('click', async (e) => {
nowPage < totalPage
? nowPage++ && (await renderArticles())
: myAlert(false, '已经是最后一页了') && console.log('已经是最后一页了');
});
/**
* 目标 4:删除功能
* 4.1 关联文章 id 到删除图标
* 4.2 点击删除时,获取文章 id
* 4.3 调用删除接口,传递文章 id 到服务器
* 4.4 重新获取文章列表,并覆盖展示
* 4.5 删除最后一页的最后一条,需要自动向前翻页
*/
async function delArticle() {
artList.addEventListener('click', async (e) => {
const delBtn = e.target.classList.contains('del');
// console.log(delBtn);
if (delBtn) {
const id = e.target.parentNode.getAttribute('data-id');
try {
const res = await axios.delete(`/v1_0/mp/articles/${id}`);
// console.log(res);
// 如果删除的是当前页面的最后一条,需要自动向前翻页
const articleNum = artList.children.length;
if (articleNum === 1 && nowPage > 1) {
nowPage--;
await renderArticles();
}
} catch (error) {
console.error(error);
}
}
});
}
delArticle();
// 点击编辑时,获取文章 id,跳转到发布文章页面传递文章 id 过去
artList.addEventListener('click', (e) => {
const editBtn = e.target.classList.contains('edit');
console.log(editBtn);
if (editBtn) {
const id = e.target.parentNode.getAttribute('data-id');
location.href = `../publish/index.html?id=${id}`;
}
});/**
* 目标 4:编辑 - 回显文章
* 4.1 页面跳转传参(URL 查询参数方式)
* 4.2 发布文章页面接收参数判断(共用同一套表单)
* 4.3 修改标题和按钮文字
* 4.4 获取文章详情数据并回显表单
*/
(async () => {
// 发布文章页面接收参数判断(共用同一套表单)
const params = new URLSearchParams(location.search);
for (const [key, value] of params) {
// 当前有要编辑的文章 id 被传入过来
if (key === 'id') {
// 修改标题和按钮文字
document.querySelector('.title span').innerHTML = '修改文章';
sendBtn.innerHTML = '修改';
// 获取文章详情数据并回显表单
const res = await axios.get(`/v1_0/mp/articles/${value}`);
console.log(res);
// 准备数据对象
const dataObj = {
channel_id: res.data.channel_id,
title: res.data.title,
rounded: res.data.cover.images[0],
content: res.data.content,
id: res.data.id,
};
// 将数据插入到页面元素中:遍历数据对象属性,映射到页面元素上,快速赋值
for (const key of Object.keys(dataObj)) {
if (key === 'rounded') {
// 封面设置
if (dataObj[key]) {
rounded.src = dataObj[key];
rounded.classList.add('show');
place.classList.add('hide');
}
} else if (key === 'content') {
// 富文本编辑器内容设置
editor.setHtml(dataObj[key]);
} else {
// 其他表单元素:频道,标题,id
// 用数据对象属性名,作为标签 name 属性选择器值来找到匹配的标签
document.querySelector(`[name=${key}]`).value = dataObj[key];
}
}
}
}
})();
/**
* 目标 5:编辑 - 保存文章
* 5.1 判断按钮文字,区分业务(因为共用一套表单)
* 5.2 调用编辑文章接口,保存信息到服务器
* 5.3 基于 Alert 反馈结果消息给用户
*/
sendBtn.addEventListener('click', async (e) => {
if (e.target.innerHTML !== '修改') return;
const formData = serialize(form, { hash: true, empty: true });
console.log(formData); // channel_id, content, id, title
// formData 中少了 cover(type, images) 属性
formData.cover = {
type: rounded.src ? 1 : 0, // 文章封面类型,默认传递 1-1 张图
images: [rounded.src], // 文章封面地址数组
};
try {
const res = await axios.put(`/v1_0/mp/articles/${formData.id}`, formData);
console.log(res);
myAlert(true, `修改成功 ~ ${res.message}`);
// 1.5s 后跳转到内容管理页面
setTimeout(() => {
window.location.href = '../content/index.html';
}, 1500);
} catch (error) {
console.dir(error);
myAlert(false, `修改失败 ~ ${error.response.data.message}`);
}
});