使用 fetch 调用学员 API
前面的学员列表来自本地数组。现在页面要从测试 API 查询学员,并把新增、修改、删除操作提交给服务端。本节围绕一次请求的完整过程学习 fetch():准备地址和数据、发送请求、检查响应、读取结果,再用 Network 核对。
完成本节后,应能独立写出 GET 与 JSON 写请求,知道什么时候读取 response.json(),以及为什么删除成功后不读取响应体。错误详情与 CORS 放到下一节。
准备测试环境
在课程目录打开终端,启动测试 API:
cd .code/api_server
uv sync
uv run uvicorn app:app --reload --port 8000访问 http://127.0.0.1:8000/api/health,应看到 "status": "ok"。再回到课程目录,另开终端启动静态页面:
python -m http.server 5500下面的案例使用 http://127.0.0.1:8000 作为 API 基础地址。完整启动步骤见 .code/api_server/README.md,字段与状态码以 .code/api_server/API.md 为准。课程只调用测试 API,不讲它的服务端实现。
发送 GET,读取响应
fetch(url, options) 是浏览器提供的请求 API。url 是请求地址;options 可填写方法、请求头、请求体等配置。省略 options 时,默认发送 GET。fetch() 发起请求后不会让页面停止响应;沿用上一节的 await 等待浏览器收到响应。
先在 Console 运行:
async function checkApi() {
try {
const response = await fetch("http://127.0.0.1:8000/api/health");
console.log(response.status, response.ok);
if (!response.ok) {
throw new Error(`请求失败:${response.status}`);
}
const data = await response.json();
console.log(data.message);
} catch (error) {
console.error(error);
}
}
checkApi();await fetch() 得到的是 Response,不是 JSON 数据;await response.json() 再等待响应体解析。请求成功时,Console 会显示 200 true 和“测试 API 运行正常”。
Response 成员 | 作用 |
|---|---|
ok | 状态码为 200~299 时是 true |
status | HTTP 状态码,例如 200、404 |
url、headers | 最终响应地址与响应头 |
json() | 把 JSON 响应体解析成 JavaScript 数据 |
text() | 把响应体读取为字符串 |
服务器返回 404 或 500 时,fetch() 通常仍会得到 Response。因此要检查 response.ok;断网等无法得到响应的情况才会让请求本身失败。具体错误分类留到下一节。响应体通常只能读取一次,保存 await response.json() 的结果供后续使用,不要对同一个响应重复调用 json()。
第一个页面练习查询 1 号学员。打开 http://127.0.0.1:5500/.code/p04/c10-fetch/01-first-fetch.html,点击按钮,观察状态码、ok 和返回的学员字段。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>第一个 Fetch GET 请求</title>
<style>
body { max-width: 700px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
button { padding: 10px 16px; }
pre { padding: 16px; background: #17212b; color: #d9f4e8; overflow: auto; }
</style>
</head>
<body>
<h1>查询 1 号学员</h1>
<button id="load-button" type="button">发送 GET 请求</button>
<pre id="result">请先启动测试 API</pre>
<script>
document.querySelector("#load-button").addEventListener("click", async () => {
const result = document.querySelector("#result");
result.textContent = "正在请求...";
try {
const response = await fetch("http://127.0.0.1:8000/api/students/1");
const data = await response.json();
result.textContent = JSON.stringify({ status: response.status, ok: response.ok, data }, null, 2);
} catch (error) {
result.textContent = `网络请求失败:${error.message}`;
}
});
</script>
</body>
</html>GET 查询参数与列表结果
学员列表接口为 GET /api/students。搜索条件放在 URL 查询参数中,普通 GET 不发送 JSON 请求体。接口约定如下:
| 参数 | 用途 | 示例 |
|---|---|---|
page、page_size | 页码与每页条数 | 1、4 |
keyword | 搜索姓名、手机号或地址 | 张 |
class_name | 班级筛选 | Python 1 班 |
disabled | 状态筛选 | true 或 false |
使用 URLSearchParams 生成和编码查询参数,避免手动拼接中文、空格、? 和 &:
const API_BASE_URL = "http://127.0.0.1:8000";
async function getStudents(page = 1, keyword = "") {
const params = new URLSearchParams();
params.set("page", String(page));
params.set("page_size", "4");
if (keyword.trim()) {
params.set("keyword", keyword.trim());
}
const response = await fetch(`${API_BASE_URL}/api/students?${params}`);
if (!response.ok) {
throw new Error(`查询失败:${response.status}`);
}
return response.json();
}
async function showStudents() {
try {
console.log(await getStudents(1, "张"));
} catch (error) {
console.error(error);
}
}
showStudents();set(name, value) 会替换同名旧值;append() 可以追加同名参数;get() 读取参数,delete() 删除参数,toString() 生成查询字符串。参数值最终都是字符串。例如 "false" 仍是有效的筛选值,不要用 Boolean("false") 转换,它会得到 true。班级和状态为空时可以不发送对应参数;字段名必须使用接口规定的 class_name、disabled。
列表响应中,total 是符合条件的总数,items 是当前页的学员数组:
{
"total": 12,
"items": [
{ "id": 1, "name": "张三", "class_name": "Python 1 班", "disabled": false }
]
}这里省略了示例学员的其他字段;实际字段见 API 规范。列表只渲染 result.items,页数可用 Math.max(1, Math.ceil(result.total / pageSize)) 计算。空 items 是查询成功但没有匹配数据。搜索条件变化后从第 1 页重查,避免沿用旧页码而得到空页。接口只返回当前页,不能把当前页的 items 当成完整数据再做全量筛选。
按顺序查看查询参数与列表渲染两个练习。页面中的姓名、班级、手机号使用 textContent 写入;班级选项来自 GET /api/classes。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>构造 GET 查询参数</title>
<style>
body { max-width: 720px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
form { display: grid; gap: 10px; }
input, select, button { padding: 9px; }
code { display: block; margin-top: 16px; padding: 14px; background: #f2f5f7; overflow-wrap: anywhere; }
</style>
</head>
<body>
<h1>使用 URLSearchParams</h1>
<form id="search-form">
<input id="keyword" placeholder="姓名或手机号" />
<select id="class-name"><option value="">全部班级</option><option>Python 1 班</option><option>Python 2 班</option></select>
<button>构造并发送请求</button>
</form>
<code id="request-url">等待操作</code>
<p id="result"></p>
<script>
document.querySelector("#search-form").addEventListener("submit", async (event) => {
event.preventDefault();
const params = new URLSearchParams({ page: "1", page_size: "5" });
const keyword = document.querySelector("#keyword").value.trim();
const className = document.querySelector("#class-name").value;
if (keyword) params.set("keyword", keyword);
if (className) params.set("class_name", className);
const url = `http://127.0.0.1:8000/api/students?${params}`;
document.querySelector("#request-url").textContent = url;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`请求失败,状态码:${response.status}`);
}
const data = await response.json();
document.querySelector("#result").textContent = `接口返回 ${data.items.length} 条,本次查询共 ${data.total} 条`;
} catch (error) {
document.querySelector("#result").textContent = `请求失败:${error.message}`;
}
});
</script>
</body>
</html><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>渲染接口列表</title>
<style>
body { max-width: 760px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
button { padding: 9px 14px; }
table { width: 100%; margin-top: 18px; border-collapse: collapse; }
th, td { padding: 11px; border-bottom: 1px solid #d9e0e8; text-align: left; }
</style>
</head>
<body>
<h1>加载并安全渲染学员列表</h1>
<button id="load-button" type="button">加载列表</button>
<p id="status">等待操作</p>
<table><thead><tr><th>姓名</th><th>班级</th><th>手机号</th></tr></thead><tbody id="student-body"></tbody></table>
<script>
function renderStudents(students) {
const rows = students.map((student) => {
const row = document.createElement("tr");
for (const value of [student.name, student.class_name, student.mobile]) {
const cell = document.createElement("td");
cell.textContent = value;
row.append(cell);
}
return row;
});
document.querySelector("#student-body").replaceChildren(...rows);
document.querySelector("#status").textContent = students.length ? `已显示 ${students.length} 条` : "没有符合条件的数据";
}
document.querySelector("#load-button").addEventListener("click", async () => {
try {
const response = await fetch("http://127.0.0.1:8000/api/students?page=1&page_size=5");
if (!response.ok) {
throw new Error(`请求失败,状态码:${response.status}`);
}
const data = await response.json();
renderStudents(data.items);
} catch (error) {
document.querySelector("#status").textContent = `加载失败:${error.message}`;
}
});
</script>
</body>
</html>请求较慢或结果过期
查询页面还需要显示加载状态,并处理连续输入。防抖通过清除旧计时器,等停止输入约 300 毫秒再查询,减少请求次数。已经发出的旧查询可能晚于新查询返回,此时可用 AbortController 取消旧请求:
let controller = null;
async function searchStudents(keyword) {
controller?.abort();
controller = new AbortController();
const current = controller;
const params = new URLSearchParams({ keyword });
try {
const response = await fetch(
`${API_BASE_URL}/api/students?${params}`,
{ signal: current.signal },
);
if (!response.ok) throw new Error(`查询失败:${response.status}`);
const result = await response.json();
if (!current.signal.aborted) console.log(result.items);
} catch (error) {
if (!current.signal.aborted) console.error(error);
}
}每次请求使用新的控制器;主动取消引起的 AbortError 不应显示为页面故障。取消浏览器等待不代表服务端撤销了写入操作,所以这项处理用于查询。完整页面还会管理加载、空结果、页码和按钮状态,这些属于页面逻辑,本节只需看清它们与请求的连接。
学员查询与分页页面
打开 http://127.0.0.1:5500/.code/p04/c10-fetch/04-student-query.html。搜索“张”、切换每页条数和页码,对照页面 URL 与 Network 请求参数。页面还通过 GET /api/classes 加载班级选项,显示加载、空结果和基础错误状态。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>使用 fetch 查询 API</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f3f6fa;
color: #303133;
font-family: Arial, "Microsoft YaHei", sans-serif;
}
button,
input,
select {
font: inherit;
}
.page-container {
width: min(1160px, calc(100% - 32px));
margin: 36px auto;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.page-title {
margin: 0 0 8px;
font-size: 28px;
}
.page-description {
margin: 0;
color: #606266;
line-height: 1.7;
}
.api-badge {
flex: 0 0 auto;
padding: 8px 12px;
border-radius: 16px;
background: #e8f8f0;
color: #1b9a59;
font-size: 13px;
}
.panel {
margin-bottom: 18px;
padding: 22px;
border-radius: 10px;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 5%);
}
.search-form {
display: grid;
grid-template-columns: minmax(190px, 1.4fr) repeat(3, minmax(145px, 1fr)) auto auto;
align-items: end;
gap: 13px;
}
.form-field {
display: grid;
gap: 7px;
}
.form-field label {
color: #606266;
font-size: 13px;
}
.form-field input,
.form-field select {
width: 100%;
height: 40px;
padding: 0 11px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #fff;
outline: none;
}
.form-field input:focus,
.form-field select:focus {
border-color: #409eff;
box-shadow: 0 0 0 3px rgb(64 158 255 / 12%);
}
.search-button,
.reset-button,
.page-button {
height: 40px;
padding: 0 17px;
border: 0;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.search-button:active,
.reset-button:active,
.page-button:active {
transform: translateY(1px);
}
.search-button {
background: #409eff;
color: #fff;
}
.search-button:hover:not(:disabled) {
background: #66b1ff;
}
.reset-button,
.page-button {
background: #f5f7fa;
color: #606266;
}
.reset-button:hover:not(:disabled),
.page-button:hover:not(:disabled) {
background: #ebeef5;
}
button:disabled,
select:disabled,
input:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.request-box {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
margin-top: 18px;
padding: 12px 14px;
border-radius: 6px;
background: #f8fafc;
color: #606266;
font-size: 13px;
line-height: 1.6;
}
.request-url {
overflow-wrap: anywhere;
color: #409eff;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.status-message {
margin: 0;
color: #606266;
line-height: 1.5;
}
.status-message.is-success {
color: #1b9a59;
}
.status-message.is-error {
color: #d93026;
}
.table-wrapper {
overflow-x: auto;
}
.student-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
.student-table th,
.student-table td {
padding: 14px 12px;
border-bottom: 1px solid #ebeef5;
text-align: left;
}
.student-table th {
background: #f8fafc;
color: #606266;
font-size: 13px;
font-weight: 600;
}
.student-table tbody tr:hover:not(.state-row) {
background: #fafcff;
}
.avatar {
width: 38px;
height: 38px;
border-radius: 50%;
background: #ebeef5;
object-fit: cover;
vertical-align: middle;
}
.status-badge {
display: inline-block;
min-width: 55px;
padding: 4px 8px;
border-radius: 12px;
background: #e8f8f0;
color: #1b9a59;
font-size: 12px;
text-align: center;
}
.status-badge.is-disabled {
background: #ebeef5;
color: #606266;
}
.state-cell {
height: 170px;
color: #909399;
text-align: center !important;
}
.state-cell.is-error {
color: #d93026;
}
.spinner {
display: inline-block;
width: 22px;
height: 22px;
margin-right: 8px;
border: 3px solid #d9ecff;
border-top-color: #409eff;
border-radius: 50%;
vertical-align: middle;
animation: rotate 0.8s linear infinite;
}
.pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 18px;
}
.page-summary {
color: #606266;
font-size: 14px;
}
.page-button {
min-width: 82px;
}
@keyframes rotate {
to {
transform: rotate(360deg);
}
}
@media (max-width: 980px) {
.search-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
.page-container {
margin: 24px auto;
}
.page-header,
.table-header {
align-items: flex-start;
flex-direction: column;
}
.search-form {
grid-template-columns: 1fr;
}
.request-box {
grid-template-columns: 1fr;
}
.pagination {
justify-content: space-between;
}
.page-summary {
text-align: center;
}
}
</style>
</head>
<body>
<main class="page-container">
<header class="page-header">
<div>
<h1 class="page-title">学员查询与分页</h1>
<p class="page-description">
使用 fetch 调用课程测试 API,并通过查询参数完成搜索、筛选和分页。
</p>
</div>
<span class="api-badge">GET /api/students</span>
</header>
<section class="panel">
<form id="search-form" class="search-form">
<div class="form-field">
<label for="keyword">关键字</label>
<input
id="keyword"
name="keyword"
placeholder="姓名、手机号或地址"
autocomplete="off"
/>
</div>
<div class="form-field">
<label for="class-name">班级</label>
<select id="class-name" name="class_name">
<option value="">全部班级</option>
</select>
</div>
<div class="form-field">
<label for="disabled">状态</label>
<select id="disabled" name="disabled">
<option value="">全部状态</option>
<option value="false">正常</option>
<option value="true">已停用</option>
</select>
</div>
<div class="form-field">
<label for="page-size">每页数量</label>
<select id="page-size" name="page_size">
<option value="4" selected>4 条</option>
<option value="6">6 条</option>
<option value="10">10 条</option>
</select>
</div>
<button id="search-button" class="search-button" type="submit">
查询
</button>
<button id="reset-button" class="reset-button" type="button">
重置
</button>
</form>
<div class="request-box">
<strong>请求地址</strong>
<code id="request-url" class="request-url">等待发送请求</code>
</div>
</section>
<section class="panel">
<header class="table-header">
<p id="status-message" class="status-message" role="status">
正在准备页面...
</p>
</header>
<div class="table-wrapper">
<table class="student-table">
<thead>
<tr>
<th>头像</th>
<th>编号</th>
<th>姓名</th>
<th>班级</th>
<th>手机号</th>
<th>地址</th>
<th>状态</th>
</tr>
</thead>
<tbody id="student-body"></tbody>
</table>
</div>
<footer class="pagination">
<button id="previous-button" class="page-button" type="button">
上一页
</button>
<span id="page-summary" class="page-summary">第 1 / 1 页,共 0 条</span>
<button id="next-button" class="page-button" type="button">
下一页
</button>
</footer>
</section>
</main>
<script>
const API_BASE_URL = "http://127.0.0.1:8000";
const searchForm = document.querySelector("#search-form");
const keywordInput = document.querySelector("#keyword");
const classSelect = document.querySelector("#class-name");
const disabledSelect = document.querySelector("#disabled");
const pageSizeSelect = document.querySelector("#page-size");
const searchButton = document.querySelector("#search-button");
const resetButton = document.querySelector("#reset-button");
const requestUrl = document.querySelector("#request-url");
const statusMessage = document.querySelector("#status-message");
const studentBody = document.querySelector("#student-body");
const previousButton = document.querySelector("#previous-button");
const nextButton = document.querySelector("#next-button");
const pageSummary = document.querySelector("#page-summary");
let currentPage = 1;
let totalPages = 1;
let isLoading = false;
function showMessage(text, type = "") {
statusMessage.textContent = text;
statusMessage.className = "status-message";
if (type) {
statusMessage.classList.add(`is-${type}`);
}
}
function createStateRow(text, type = "") {
const row = document.createElement("tr");
const cell = document.createElement("td");
row.classList.add("state-row");
cell.colSpan = 7;
cell.classList.add("state-cell");
if (type === "loading") {
const spinner = document.createElement("span");
spinner.classList.add("spinner");
spinner.setAttribute("aria-hidden", "true");
cell.append(spinner, text);
} else {
cell.textContent = text;
}
if (type === "error") {
cell.classList.add("is-error");
}
row.append(cell);
return row;
}
function createTextCell(text) {
const cell = document.createElement("td");
cell.textContent = text;
return cell;
}
function getAssetUrl(path) {
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
return `${API_BASE_URL}${path}`;
}
function createStudentRow(student) {
const row = document.createElement("tr");
const avatarCell = document.createElement("td");
const avatar = document.createElement("img");
const statusCell = document.createElement("td");
const status = document.createElement("span");
row.dataset.studentId = String(student.id);
avatar.classList.add("avatar");
avatar.src = getAssetUrl(student.avatar);
avatar.alt = `${student.name}的头像`;
avatarCell.append(avatar);
status.classList.add("status-badge");
status.classList.toggle("is-disabled", student.disabled);
status.textContent = student.disabled ? "已停用" : "正常";
statusCell.append(status);
row.append(
avatarCell,
createTextCell(String(student.id)),
createTextCell(student.name),
createTextCell(student.class_name),
createTextCell(student.mobile),
createTextCell(student.address || "暂未填写"),
statusCell,
);
return row;
}
function renderStudents(students) {
if (students.length === 0) {
studentBody.replaceChildren(
createStateRow("没有找到符合条件的学员"),
);
return;
}
const fragment = document.createDocumentFragment();
for (const student of students) {
fragment.append(createStudentRow(student));
}
studentBody.replaceChildren(fragment);
}
function createStudentParams(page) {
const params = new URLSearchParams();
const keyword = keywordInput.value.trim();
const className = classSelect.value;
const disabled = disabledSelect.value;
params.set("page", String(page));
params.set("page_size", pageSizeSelect.value);
if (keyword) {
params.set("keyword", keyword);
}
if (className) {
params.set("class_name", className);
}
if (disabled) {
params.set("disabled", disabled);
}
return params;
}
async function getStudents(page) {
const params = createStudentParams(page);
const url = `${API_BASE_URL}/api/students?${params}`;
requestUrl.textContent = url;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`请求失败,状态码:${response.status}`);
}
return response.json();
}
function setLoadingState(loading) {
searchButton.disabled = loading;
resetButton.disabled = loading;
keywordInput.disabled = loading;
classSelect.disabled = loading;
disabledSelect.disabled = loading;
pageSizeSelect.disabled = loading;
searchButton.textContent = loading ? "查询中..." : "查询";
if (loading) {
previousButton.disabled = true;
nextButton.disabled = true;
}
}
function updatePagination(total) {
pageSummary.textContent =
`第 ${currentPage} / ${totalPages} 页,共 ${total} 条`;
previousButton.disabled = isLoading || currentPage <= 1;
nextButton.disabled = isLoading || currentPage >= totalPages;
}
async function loadStudents(page = 1) {
if (isLoading) {
return;
}
isLoading = true;
setLoadingState(true);
showMessage("正在查询学员数据...");
studentBody.replaceChildren(createStateRow("正在加载...", "loading"));
try {
const result = await getStudents(page);
const pageSize = Number(pageSizeSelect.value);
currentPage = page;
totalPages = Math.max(1, Math.ceil(result.total / pageSize));
pageSummary.dataset.total = String(result.total);
renderStudents(result.items);
showMessage(`查询成功,当前页 ${result.items.length} 条`, "success");
} catch (error) {
console.error(error);
currentPage = 1;
totalPages = 1;
pageSummary.dataset.total = "0";
studentBody.replaceChildren(createStateRow(error.message, "error"));
showMessage(error.message, "error");
} finally {
isLoading = false;
setLoadingState(false);
updatePagination(Number(pageSummary.dataset.total || 0));
}
}
async function loadClassOptions() {
const response = await fetch(`${API_BASE_URL}/api/classes`);
if (!response.ok) {
throw new Error(`班级加载失败,状态码:${response.status}`);
}
const result = await response.json();
for (const className of result.items) {
const option = document.createElement("option");
option.value = className;
option.textContent = className;
classSelect.append(option);
}
}
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
loadStudents(1);
});
resetButton.addEventListener("click", () => {
searchForm.reset();
loadStudents(1);
keywordInput.focus();
});
pageSizeSelect.addEventListener("change", () => {
loadStudents(1);
});
previousButton.addEventListener("click", () => {
if (currentPage > 1) {
loadStudents(currentPage - 1);
}
});
nextButton.addEventListener("click", () => {
if (currentPage < totalPages) {
loadStudents(currentPage + 1);
}
});
async function initializePage() {
try {
await loadClassOptions();
} catch (error) {
console.error(error);
showMessage(error.message, "error");
}
await loadStudents(1);
}
initializePage();
</script>
</body>
</html>用 JSON 发送写请求
写请求通常给 fetch() 增加第二个参数。method 指定方法,headers 描述请求体格式,body 放实际数据。当前测试 API 的约定是:
| 操作 | 方法与地址 | 请求体 | 成功状态 |
|---|---|---|---|
| 新增 | POST /api/students | 完整新增字段 | 201 与学员 JSON |
| 完整编辑 | PUT /api/students/{id} | 全部可编辑字段 | 200 与学员 JSON |
| 修改状态 | PATCH /api/students/{id}/disabled | 只含 disabled | 200 与学员 JSON |
| 删除 | DELETE /api/students/{id} | 无 | 204,无响应体 |
具体方法和字段以 API 契约为准;前端不能随意把不同操作全部改为 POST。
POST:请求头与 JSON 请求体
假设表单已经得到以下对象:
const studentData = {
name: "林小满",
class_name: "Python 1 班",
mobile: "13900000001",
address: "湖南长沙",
disabled: false,
};对象要用 JSON.stringify() 转成 JSON 字符串,并用 Content-Type 告诉服务端如何解析:
async function createStudent(data) {
const response = await fetch(`${API_BASE_URL}/api/students`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`新增失败:${response.status}`);
}
return response.json();
}
async function submitStudent() {
try {
console.log(await createStudent(studentData));
} catch (error) {
console.error(error);
}
}
submitStudent();成功响应返回服务端生成的 ID、头像等字段,不能在前端用数组长度猜 ID。重复手机号会得到 400;练习时更换手机号。Content-Type 与 JSON 字符串请求体要配套,否则接口可能无法解析。
普通表单可用 new FormData(form) 读取字段,再组成普通对象发送 JSON。未选中的复选框不会进入 FormData,布尔状态直接读 checked:
const form = document.querySelector("#student-form");
const formData = new FormData(form);
const data = {
name: String(formData.get("name") ?? "").trim(),
class_name: String(formData.get("class_name") ?? ""),
mobile: String(formData.get("mobile") ?? "").trim(),
address: String(formData.get("address") ?? "").trim(),
disabled: form.elements.namedItem("disabled").checked,
};这段代码依赖页面中的 #student-form 和相应 name 字段;完整表单在练习附件中。这里的 FormData 只用于读取普通字段,最终请求体仍是 JSON。文件上传使用 FormData 作为请求体,由浏览器设置请求头,留到后面的上传章节。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>POST 新增学员</title>
<style>
body { max-width: 700px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
form { display: grid; gap: 10px; }
input, select, button { padding: 9px; }
pre { padding: 16px; background: #18222d; color: #daf4e8; overflow: auto; }
</style>
</head>
<body>
<h1>使用 POST 提交 JSON</h1>
<form id="student-form">
<label>姓名 <input name="name" value="测试学员" required /></label>
<label>班级 <select name="class_name"><option>Python 1 班</option><option>Web 全栈班</option></select></label>
<label>手机号 <input name="mobile" type="tel" value="13900000001" required /></label>
<label>地址 <input name="address" value="湖南长沙" /></label>
<button>新增学员</button>
</form>
<pre id="result">请先启动测试 API</pre>
<script>
document.querySelector("#student-form").addEventListener("submit", async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const student = Object.fromEntries(formData.entries());
const result = document.querySelector("#result");
try {
const response = await fetch("http://127.0.0.1:8000/api/students", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(student),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
result.textContent = JSON.stringify({ status: response.status, data }, null, 2);
} catch (error) {
result.textContent = `新增请求失败:${error.message}`;
}
});
</script>
</body>
</html>PUT 与 PATCH:完整字段和局部字段
当前接口要求 PUT 提交姓名、班级、手机号、地址和状态,路径中的 ID 决定修改哪名学员。PATCH 状态接口只接收 disabled:
async function updateStudent(studentId, data) {
const response = await fetch(`${API_BASE_URL}/api/students/${studentId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`修改失败:${response.status}`);
return response.json();
}
async function updateStudentStatus(studentId, disabled) {
const response = await fetch(
`${API_BASE_URL}/api/students/${studentId}/disabled`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ disabled }),
},
);
if (!response.ok) throw new Error(`状态修改失败:${response.status}`);
return response.json();
}页面编辑时先把原有字段回填到表单,再组成完整对象交给 updateStudent()。切换状态时传入 !student.disabled。两个接口成功后都返回更新后的学员数据。练习页面先读取 1 号学员,再分别发 PUT 和 PATCH;在 Network 的 Payload 中比较两种请求体。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PUT 与 PATCH</title>
<style>
body { max-width: 720px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
form { display: grid; gap: 10px; }
input, select, button { padding: 9px; }
.actions { display: flex; gap: 8px; }
pre { padding: 16px; background: #18222d; color: #daf4e8; overflow: auto; }
</style>
</head>
<body>
<h1>完整修改与局部修改</h1>
<form id="student-form">
<label>学员 ID <input name="id" type="number" value="1" min="1" /></label>
<label>姓名 <input name="name" required /></label>
<label>班级 <select name="class_name"><option>Python 1 班</option><option>Python 2 班</option><option>Web 全栈班</option></select></label>
<label>手机号 <input name="mobile" required /></label>
<label>地址 <input name="address" /></label>
<label><input name="disabled" type="checkbox" /> 已停用</label>
<div class="actions"><button id="load-button" type="button">读取学员</button><button id="put-button" type="button">PUT 完整修改</button><button id="patch-button" type="button">PATCH 保存状态</button></div>
</form>
<pre id="result">先读取学员,再修改字段或状态。</pre>
<script>
const form = document.querySelector("#student-form");
const result = document.querySelector("#result");
let loadedStudentId = null;
form.elements.namedItem("id").addEventListener("input", () => {
loadedStudentId = null;
result.textContent = "学员 ID 已变化,请先点击“读取学员”。";
});
async function loadStudent() {
const studentId = form.elements.namedItem("id").value;
loadedStudentId = null;
if (!Number.isInteger(Number(studentId)) || Number(studentId) < 1 || studentId === "") {
result.textContent = "请输入大于 0 的学员 ID。";
return;
}
result.textContent = "正在读取学员...";
try {
const response = await fetch(`http://127.0.0.1:8000/api/students/${studentId}`);
if (!response.ok) {
throw new Error(`读取失败,状态码:${response.status}`);
}
const student = await response.json();
if (form.elements.namedItem("id").value !== studentId) {
return; // 等待期间切换了 ID,旧响应不能回填当前表单。
}
form.elements.namedItem("name").value = student.name;
form.elements.namedItem("class_name").value = student.class_name;
form.elements.namedItem("mobile").value = student.mobile;
form.elements.namedItem("address").value = student.address;
form.elements.namedItem("disabled").checked = student.disabled;
loadedStudentId = studentId;
result.textContent = `已读取 ${student.name}(ID ${studentId}),可修改字段后提交。`;
} catch (error) {
result.textContent = error.message;
}
}
async function sendJson(method, body, pathSuffix = "") {
const studentId = form.elements.namedItem("id").value;
if (studentId !== loadedStudentId) {
result.textContent = "请先读取当前 ID 对应的学员。";
return;
}
try {
const response = await fetch(`http://127.0.0.1:8000/api/students/${studentId}${pathSuffix}`, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
form.elements.namedItem("disabled").checked = data.disabled;
result.textContent = JSON.stringify({ method, status: response.status, data }, null, 2);
} catch (error) {
result.textContent = `${method} 请求失败:${error.message}`;
}
}
document.querySelector("#load-button").addEventListener("click", loadStudent);
document.querySelector("#put-button").addEventListener("click", () => sendJson("PUT", {
name: form.elements.namedItem("name").value.trim(),
class_name: form.elements.namedItem("class_name").value,
mobile: form.elements.namedItem("mobile").value.trim(),
address: form.elements.namedItem("address").value.trim(),
disabled: form.elements.namedItem("disabled").checked,
}));
document.querySelector("#patch-button").addEventListener("click", () => sendJson("PATCH", {
disabled: form.elements.namedItem("disabled").checked,
}, "/disabled"));
loadStudent();
</script>
</body>
</html>DELETE:处理空响应体
删除请求只有地址和方法,没有 JSON 请求体。当前 API 成功时返回 204 No Content,不能再调用 response.json():
async function deleteStudent(studentId) {
const response = await fetch(
`${API_BASE_URL}/api/students/${studentId}`,
{ method: "DELETE" },
);
if (!response.ok) throw new Error(`删除失败:${response.status}`);
}页面发送删除前应让用户确认;取消时不发送请求。先用不存在的 ID 999 观察 404,再选真实学员确认删除;Network 中的 Response 为空是正常结果。若接口返回其他非 JSON 内容,可按契约使用 response.text(),不要无条件解析 JSON。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DELETE 删除学员</title>
<style>
body { max-width: 650px; margin: 50px auto; padding: 0 20px; font-family: sans-serif; }
input, button { padding: 10px; }
#message { padding: 14px; background: #fff4f2; }
</style>
</head>
<body>
<h1>处理 DELETE 与 204</h1>
<label>学员 ID <input id="student-id" type="number" value="999" min="1" /></label>
<button id="delete-button" type="button">删除</button>
<p id="message">默认 ID 999 用于观察 404;删除真实数据前浏览器会再次确认。</p>
<script>
document.querySelector("#delete-button").addEventListener("click", async () => {
const studentId = document.querySelector("#student-id").value;
if (!window.confirm(`确认删除学员 ${studentId} 吗?`)) return;
const message = document.querySelector("#message");
try {
const response = await fetch(`http://127.0.0.1:8000/api/students/${studentId}`, { method: "DELETE" });
if (response.status === 204) {
message.textContent = "删除成功,204 响应没有 JSON 正文";
return;
}
const data = await response.json();
message.textContent = `删除失败(${response.status}):${data.detail ?? "未知错误"}`;
} catch (error) {
message.textContent = `网络请求失败:${error.message}`;
}
});
</script>
</body>
</html>从请求回到页面
查询成功后用 items 更新列表;写入成功后重新 GET,页面才能显示最新数据。提交按钮在请求期间禁用,并在 finally 恢复。写入失败时保留表单内容,方便修改;若写入成功而列表刷新失败,应分别提示,不能把成功写入误报为失败。
前面的查询页面和下面的写操作页面把请求代码接到表单、列表、空状态和按钮上。写操作实验还提供 POST /api/test/reset 按钮,用于恢复内存中的测试数据;它不提交学员 JSON。
学员写操作实验
打开 http://127.0.0.1:5500/.code/p04/c10-fetch/08-write-lab.html。依次新增、编辑、切换状态、删除,最后点击“恢复测试数据”。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>使用 fetch 提交 JSON</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f3f6fa;
color: #303133;
font-family: Arial, "Microsoft YaHei", sans-serif;
}
button,
input,
select {
font: inherit;
}
[hidden] {
display: none !important;
}
.page-container {
width: min(1160px, calc(100% - 32px));
margin: 36px auto;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.page-title {
margin: 0 0 8px;
font-size: 28px;
}
.page-description {
margin: 0;
color: #606266;
line-height: 1.7;
}
.reset-data-button,
.submit-button,
.cancel-button,
.action-button {
border: 0;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.reset-data-button:active,
.submit-button:active,
.cancel-button:active,
.action-button:active {
transform: translateY(1px);
}
.reset-data-button {
flex: 0 0 auto;
padding: 10px 15px;
background: #fff;
color: #606266;
box-shadow: 0 2px 10px rgb(0 0 0 / 7%);
}
.reset-data-button:hover:not(:disabled) {
background: #f5f7fa;
}
button:disabled,
input:disabled,
select:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.panel {
margin-bottom: 18px;
padding: 22px;
border-radius: 10px;
background: #fff;
box-shadow: 0 8px 24px rgb(0 0 0 / 5%);
}
.form-title {
margin: 0 0 18px;
font-size: 19px;
}
.student-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: end;
gap: 14px;
}
.form-field {
display: grid;
gap: 7px;
}
.form-field label {
color: #606266;
font-size: 13px;
}
.form-field input,
.form-field select {
width: 100%;
height: 40px;
padding: 0 11px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #fff;
outline: none;
}
.form-field input:focus,
.form-field select:focus {
border-color: #409eff;
box-shadow: 0 0 0 3px rgb(64 158 255 / 12%);
}
.checkbox-field {
display: flex;
align-items: center;
min-height: 40px;
}
.checkbox-field label {
display: inline-flex;
align-items: center;
gap: 7px;
color: #606266;
cursor: pointer;
}
.checkbox-field input {
width: 17px;
height: 17px;
accent-color: #409eff;
}
.form-actions {
display: flex;
gap: 9px;
}
.submit-button,
.cancel-button {
height: 40px;
padding: 0 18px;
}
.submit-button {
min-width: 104px;
background: #409eff;
color: #fff;
}
.submit-button:hover:not(:disabled) {
background: #66b1ff;
}
.cancel-button {
background: #f5f7fa;
color: #606266;
}
.cancel-button:hover:not(:disabled) {
background: #ebeef5;
}
.message {
min-height: 22px;
margin: 16px 0 0;
color: #606266;
font-size: 14px;
}
.message.is-success {
color: #1b9a59;
}
.message.is-error {
color: #d93026;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.table-title {
margin: 0;
font-size: 19px;
}
.student-count {
color: #606266;
font-size: 14px;
}
.table-wrapper {
overflow-x: auto;
}
.student-table {
width: 100%;
border-collapse: collapse;
white-space: nowrap;
}
.student-table th,
.student-table td {
padding: 13px 11px;
border-bottom: 1px solid #ebeef5;
text-align: left;
}
.student-table th {
background: #f8fafc;
color: #606266;
font-size: 13px;
font-weight: 600;
}
.student-table tbody tr:hover:not(.state-row) {
background: #fafcff;
}
.status-badge {
display: inline-block;
min-width: 55px;
padding: 4px 8px;
border-radius: 12px;
background: #e8f8f0;
color: #1b9a59;
font-size: 12px;
text-align: center;
}
.status-badge.is-disabled {
background: #ebeef5;
color: #606266;
}
.action-cell {
display: flex;
gap: 5px;
}
.action-button {
padding: 7px 9px;
background: transparent;
color: #409eff;
}
.action-button:hover:not(:disabled) {
background: #ecf5ff;
}
.action-button.is-warning {
color: #d48806;
}
.action-button.is-warning:hover:not(:disabled) {
background: #fff7e6;
}
.action-button.is-danger {
color: #f56c6c;
}
.action-button.is-danger:hover:not(:disabled) {
background: #fef0f0;
}
.state-cell {
height: 150px;
color: #909399;
text-align: center !important;
}
.state-cell.is-error {
color: #d93026;
}
@media (max-width: 980px) {
.student-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 620px) {
.page-container {
margin: 24px auto;
}
.page-header,
.table-header {
align-items: flex-start;
flex-direction: column;
}
.student-form {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="page-container">
<header class="page-header">
<div>
<h1 class="page-title">学员写操作实验</h1>
<p class="page-description">
使用 POST、PUT、PATCH 和 DELETE 修改课程测试 API 中的内存数据。
</p>
</div>
<button id="reset-data-button" class="reset-data-button" type="button">
恢复测试数据
</button>
</header>
<section class="panel">
<h2 id="form-title" class="form-title">新增学员</h2>
<form id="student-form" class="student-form">
<div class="form-field">
<label for="student-name">姓名</label>
<input
id="student-name"
name="name"
minlength="2"
maxlength="20"
placeholder="例如:林小满"
required
/>
</div>
<div class="form-field">
<label for="class-name">班级</label>
<select id="class-name" name="class_name" required>
<option value="">请选择班级</option>
<option value="Python 1 班">Python 1 班</option>
<option value="Python 2 班">Python 2 班</option>
<option value="Web 全栈班">Web 全栈班</option>
</select>
</div>
<div class="form-field">
<label for="mobile">手机号</label>
<input
id="mobile"
name="mobile"
inputmode="numeric"
pattern="1[3-9][0-9]{9}"
maxlength="11"
placeholder="11 位手机号"
required
/>
</div>
<div class="form-field">
<label for="address">地址</label>
<input
id="address"
name="address"
maxlength="100"
placeholder="例如:湖南长沙"
/>
</div>
<div class="checkbox-field">
<label for="student-disabled">
<input
id="student-disabled"
name="disabled"
type="checkbox"
/>
<span id="disabled-label">新增后设为停用</span>
</label>
</div>
<div class="form-actions">
<button id="submit-button" class="submit-button" type="submit">
新增学员
</button>
<button
id="cancel-button"
class="cancel-button"
type="button"
hidden
>
取消编辑
</button>
</div>
</form>
<p id="message" class="message" role="status">等待操作</p>
</section>
<section class="panel">
<header class="table-header">
<h2 class="table-title">学员列表</h2>
<span id="student-count" class="student-count">共 0 名学员</span>
</header>
<div class="table-wrapper">
<table class="student-table">
<thead>
<tr>
<th>编号</th>
<th>姓名</th>
<th>班级</th>
<th>手机号</th>
<th>地址</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="student-body"></tbody>
</table>
</div>
</section>
</main>
<script>
const API_BASE_URL = "http://127.0.0.1:8000";
const studentForm = document.querySelector("#student-form");
const formTitle = document.querySelector("#form-title");
const nameInput = document.querySelector("#student-name");
const classSelect = document.querySelector("#class-name");
const mobileInput = document.querySelector("#mobile");
const addressInput = document.querySelector("#address");
const disabledInput = document.querySelector("#student-disabled");
const disabledLabel = document.querySelector("#disabled-label");
const submitButton = document.querySelector("#submit-button");
const cancelButton = document.querySelector("#cancel-button");
const resetDataButton = document.querySelector("#reset-data-button");
const message = document.querySelector("#message");
const studentCount = document.querySelector("#student-count");
const studentBody = document.querySelector("#student-body");
const studentMap = new Map();
let editingStudentId = null;
let isSubmitting = false;
let isOperating = false;
function showMessage(text, type = "") {
message.textContent = text;
message.className = "message";
if (type) {
message.classList.add(`is-${type}`);
}
}
function createStateRow(text, type = "") {
const row = document.createElement("tr");
const cell = document.createElement("td");
row.classList.add("state-row");
cell.colSpan = 7;
cell.classList.add("state-cell");
cell.textContent = text;
if (type === "error") {
cell.classList.add("is-error");
}
row.append(cell);
return row;
}
function createTextCell(text) {
const cell = document.createElement("td");
cell.textContent = text;
return cell;
}
function createActionButton(action, text, className = "") {
const button = document.createElement("button");
button.type = "button";
button.className = `action-button ${className}`.trim();
button.dataset.action = action;
button.textContent = text;
return button;
}
function createStudentRow(student) {
const row = document.createElement("tr");
const statusCell = document.createElement("td");
const status = document.createElement("span");
const actionCell = document.createElement("td");
row.dataset.studentId = String(student.id);
status.classList.add("status-badge");
status.classList.toggle("is-disabled", student.disabled);
status.textContent = student.disabled ? "已停用" : "正常";
statusCell.append(status);
actionCell.classList.add("action-cell");
actionCell.append(
createActionButton("edit", "编辑"),
createActionButton(
"toggle",
student.disabled ? "启用" : "停用",
"is-warning",
),
createActionButton("delete", "删除", "is-danger"),
);
row.append(
createTextCell(String(student.id)),
createTextCell(student.name),
createTextCell(student.class_name),
createTextCell(student.mobile),
createTextCell(student.address || "暂未填写"),
statusCell,
actionCell,
);
return row;
}
function renderStudents(students) {
studentMap.clear();
if (students.length === 0) {
studentBody.replaceChildren(createStateRow("暂无学员数据"));
studentCount.textContent = "共 0 名学员";
return;
}
const fragment = document.createDocumentFragment();
for (const student of students) {
studentMap.set(student.id, student);
fragment.append(createStudentRow(student));
}
studentBody.replaceChildren(fragment);
studentCount.textContent = `共 ${students.length} 名学员`;
}
async function loadStudents() {
studentBody.replaceChildren(createStateRow("正在加载学员..."));
const response = await fetch(
`${API_BASE_URL}/api/students?page=1&page_size=100`,
);
if (!response.ok) {
throw new Error(`查询失败,状态码:${response.status}`);
}
const result = await response.json();
renderStudents(result.items);
}
async function refreshStudentsAfterWrite(successMessage) {
try {
await loadStudents();
showMessage(successMessage, "success");
} catch (error) {
console.error(error);
studentMap.clear();
studentBody.replaceChildren(createStateRow("列表刷新失败,请稍后刷新页面", "error"));
studentCount.textContent = "列表未更新";
showMessage(`${successMessage};列表刷新失败:${error.message}`, "error");
}
}
function getStudentPayload() {
const formData = new FormData(studentForm);
return {
name: String(formData.get("name") ?? "").trim(),
class_name: String(formData.get("class_name") ?? ""),
mobile: String(formData.get("mobile") ?? "").trim(),
address: String(formData.get("address") ?? "").trim(),
disabled: disabledInput.checked,
};
}
async function createStudent(data) {
const response = await fetch(`${API_BASE_URL}/api/students`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`新增失败,状态码:${response.status}`);
}
return response.json();
}
async function updateStudent(studentId, data) {
const response = await fetch(
`${API_BASE_URL}/api/students/${studentId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
},
);
if (!response.ok) {
throw new Error(`修改失败,状态码:${response.status}`);
}
return response.json();
}
async function updateStudentStatus(studentId, disabled) {
const response = await fetch(
`${API_BASE_URL}/api/students/${studentId}/disabled`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ disabled }),
},
);
if (!response.ok) {
throw new Error(`状态修改失败,状态码:${response.status}`);
}
return response.json();
}
async function deleteStudent(studentId) {
const response = await fetch(
`${API_BASE_URL}/api/students/${studentId}`,
{ method: "DELETE" },
);
if (!response.ok) {
throw new Error(`删除失败,状态码:${response.status}`);
}
// 删除成功返回 204,响应体为空,不调用 response.json()。
}
async function resetTestData() {
const response = await fetch(`${API_BASE_URL}/api/test/reset`, {
method: "POST",
});
if (!response.ok) {
throw new Error(`恢复失败,状态码:${response.status}`);
}
return response.json();
}
function setSubmitState(submitting) {
submitButton.disabled = submitting;
cancelButton.disabled = submitting;
resetDataButton.disabled = submitting || isOperating;
for (const button of studentBody.querySelectorAll("button")) {
button.disabled = submitting || isOperating;
}
if (submitting) {
submitButton.textContent = "提交中...";
return;
}
submitButton.textContent =
editingStudentId === null ? "新增学员" : "保存修改";
}
function setTableOperationState(operating) {
resetDataButton.disabled = operating || isSubmitting;
submitButton.disabled = operating || isSubmitting;
cancelButton.disabled = operating || isSubmitting;
for (const button of studentBody.querySelectorAll("button")) {
button.disabled = operating || isSubmitting;
}
}
function resetStudentForm() {
editingStudentId = null;
studentForm.reset();
formTitle.textContent = "新增学员";
submitButton.textContent = "新增学员";
cancelButton.hidden = true;
disabledLabel.textContent = "新增后设为停用";
}
function startEdit(student) {
editingStudentId = student.id;
nameInput.value = student.name;
classSelect.value = student.class_name;
mobileInput.value = student.mobile;
addressInput.value = student.address;
disabledInput.checked = student.disabled;
formTitle.textContent = `编辑学员 #${student.id}`;
submitButton.textContent = "保存修改";
cancelButton.hidden = false;
disabledLabel.textContent = "设为停用";
nameInput.focus();
window.scrollTo({ top: 0, behavior: "smooth" });
}
studentForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (isSubmitting || isOperating) {
return;
}
const data = getStudentPayload();
const studentId = editingStudentId;
isSubmitting = true;
setSubmitState(true);
try {
if (studentId === null) {
const student = await createStudent(data);
resetStudentForm();
await refreshStudentsAfterWrite(`新增成功:${student.name}`);
} else {
const student = await updateStudent(studentId, data);
resetStudentForm();
await refreshStudentsAfterWrite(`修改成功:${student.name}`);
}
} catch (error) {
console.error(error);
showMessage(error.message, "error");
} finally {
isSubmitting = false;
setSubmitState(false);
}
});
cancelButton.addEventListener("click", () => {
resetStudentForm();
showMessage("已取消编辑");
nameInput.focus();
});
studentBody.addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");
if (
!button ||
!studentBody.contains(button) ||
isOperating ||
isSubmitting
) {
return;
}
const row = button.closest("tr[data-student-id]");
const studentId = Number(row?.dataset.studentId);
const student = studentMap.get(studentId);
if (!student) {
showMessage("没有找到对应的学员", "error");
return;
}
if (button.dataset.action === "edit") {
startEdit(student);
return;
}
if (button.dataset.action === "delete") {
const confirmed = window.confirm(
`确认删除学员“${student.name}”吗?`,
);
if (!confirmed) {
showMessage("已取消删除");
return;
}
}
isOperating = true;
setTableOperationState(true);
try {
if (button.dataset.action === "toggle") {
const updated = await updateStudentStatus(
student.id,
!student.disabled,
);
const actionText = updated.disabled ? "停用" : "启用";
await refreshStudentsAfterWrite(`已${actionText}学员:${updated.name}`);
} else if (button.dataset.action === "delete") {
await deleteStudent(student.id);
if (editingStudentId === student.id) {
resetStudentForm();
}
await refreshStudentsAfterWrite(`已删除学员:${student.name}`);
}
} catch (error) {
console.error(error);
showMessage(error.message, "error");
} finally {
isOperating = false;
setTableOperationState(false);
}
});
resetDataButton.addEventListener("click", async () => {
if (isOperating || isSubmitting) {
return;
}
const confirmed = window.confirm("确认恢复初始 12 名学员吗?");
if (!confirmed) {
showMessage("已取消恢复");
return;
}
isOperating = true;
setTableOperationState(true);
try {
const result = await resetTestData();
resetStudentForm();
await refreshStudentsAfterWrite(result.message);
} catch (error) {
console.error(error);
showMessage(error.message, "error");
} finally {
isOperating = false;
setTableOperationState(false);
}
});
async function initializePage() {
try {
await loadStudents();
showMessage("学员数据加载完成", "success");
} catch (error) {
console.error(error);
studentBody.replaceChildren(createStateRow(error.message, "error"));
showMessage(error.message, "error");
}
}
initializePage();
</script>
</body>
</html>:::
用 Network 验证与排查
按 F12 打开开发者工具,在 Network 中选择 Fetch/XHR。每次操作后查看对应请求:
| 检查位置 | GET 查询 | JSON 写请求 |
|---|---|---|
| Headers | URL、GET、状态码 | URL、POST/PUT/PATCH/DELETE、状态码、Content-Type |
| Query String Parameters | page、keyword、class_name 等 | 通常没有查询条件 |
| Payload | 普通 GET 无 JSON 请求体 | 核对字段名、布尔值与 JSON 内容 |
| Response/Preview | total、items | 成功时的学员 JSON;DELETE 204 为空 |
页面无数据时,先看是否发出了请求,再核对地址、方法、参数、状态码和响应结构。items: [] 是空结果;response.ok === false 是 HTTP 错误;Console 中的网络异常需要另外排查。下一节会处理接口 detail、校验错误和 CORS。
小结
fetch() 的核心步骤是确定 URL 与请求配置、等待 Response、检查 ok,然后按接口约定解析响应体。GET 条件使用 URLSearchParams,JSON 写请求配合 Content-Type 与 JSON.stringify();204 没有响应体。页面状态和操作反馈围绕这条请求流程展开。