Skip to content

使用 fetch 调用学员 API ​

前面的学员列表来自本地数组。现在页面要从测试 API 查询学员,并把新增、修改、删除操作提交给服务端。本节围绕一次请求的完整过程学习 fetch():准备地址和数据、发送请求、检查响应、读取结果,再用 Network 核对。

完成本节后,应能独立写出 GET 与 JSON 写请求,知道什么时候读取 response.json(),以及为什么删除成功后不读取响应体。错误详情与 CORS 放到下一节。

准备测试环境 ​

在课程目录打开终端,启动测试 API:

bash
cd .code/api_server
uv sync
uv run uvicorn app:app --reload --port 8000

访问 http://127.0.0.1:8000/api/health,应看到 "status": "ok"。再回到课程目录,另开终端启动静态页面:

bash
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 运行:

js
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
statusHTTP 状态码,例如 200、404
url、headers最终响应地址与响应头
json()把 JSON 响应体解析成 JavaScript 数据
text()把响应体读取为字符串

服务器返回 404 或 500 时,fetch() 通常仍会得到 Response。因此要检查 response.ok;断网等无法得到响应的情况才会让请求本身失败。具体错误分类留到下一节。响应体通常只能读取一次,保存 await response.json() 的结果供后续使用,不要对同一个响应重复调用 json()。

mermaid Diagram

第一个页面练习查询 1 号学员。打开 http://127.0.0.1:5500/.code/p04/c10-fetch/01-first-fetch.html,点击按钮,观察状态码、ok 和返回的学员字段。

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 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 生成和编码查询参数,避免手动拼接中文、空格、? 和 &:

js
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 是当前页的学员数组:

json
{
  "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。

html
<!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>
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 取消旧请求:

js
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 加载班级选项,显示加载、空结果和基础错误状态。

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 查询 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只含 disabled200 与学员 JSON
删除DELETE /api/students/{id}无204,无响应体

具体方法和字段以 API 契约为准;前端不能随意把不同操作全部改为 POST。

POST:请求头与 JSON 请求体 ​

假设表单已经得到以下对象:

js
const studentData = {
  name: "林小满",
  class_name: "Python 1 班",
  mobile: "13900000001",
  address: "湖南长沙",
  disabled: false,
};

对象要用 JSON.stringify() 转成 JSON 字符串,并用 Content-Type 告诉服务端如何解析:

js
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:

js
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 作为请求体,由浏览器设置请求头,留到后面的上传章节。

html
<!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:

js
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 中比较两种请求体。

html
<!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():

js
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。

html
<!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 恢复。写入失败时保留表单内容,方便修改;若写入成功而列表刷新失败,应分别提示,不能把成功写入误报为失败。

mermaid Diagram

前面的查询页面和下面的写操作页面把请求代码接到表单、列表、空状态和按钮上。写操作实验还提供 POST /api/test/reset 按钮,用于恢复内存中的测试数据;它不提交学员 JSON。

学员写操作实验

打开 http://127.0.0.1:5500/.code/p04/c10-fetch/08-write-lab.html。依次新增、编辑、切换状态、删除,最后点击“恢复测试数据”。

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 写请求
HeadersURL、GET、状态码URL、POST/PUT/PATCH/DELETE、状态码、Content-Type
Query String Parameterspage、keyword、class_name 等通常没有查询条件
Payload普通 GET 无 JSON 请求体核对字段名、布尔值与 JSON 内容
Response/Previewtotal、items成功时的学员 JSON;DELETE 204 为空

页面无数据时,先看是否发出了请求,再核对地址、方法、参数、状态码和响应结构。items: [] 是空结果;response.ok === false 是 HTTP 错误;Console 中的网络异常需要另外排查。下一节会处理接口 detail、校验错误和 CORS。

小结 ​

fetch() 的核心步骤是确定 URL 与请求配置、等待 Response、检查 ok,然后按接口约定解析响应体。GET 条件使用 URLSearchParams,JSON 写请求配合 Content-Type 与 JSON.stringify();204 没有响应体。页面状态和操作反馈围绕这条请求流程展开。