原创

Vue 3 商品管理完整教程:搜索分页、增删改查与接口联调

做商品后台,先把搜索、分页、新增、编辑和删除连成一条能用的流程,比先选一套复杂组件更有帮助。列表能显示还不算完成:保存失败时输入不能丢,删除最后一页的唯一商品后不能停在空页,搜索返回慢了也不能盖住新的查询。

这份项目只用 Vue 3 和浏览器 Fetch。接口是独立 Node 服务,数据保存在内存,便于从零运行和观察请求;重启接口会恢复初始商品。它适合学习页面与接口怎么配合,接入正式数据库时保持相同接口合同,再替换后端存储实现。

跑起来以后能做什么

页面每页显示三条商品,按商品名称搜索,新增或修改名称、价格和库存,确认后删除。所有写操作都经过接口,页面不会只改本地数组就显示成功。价格以整数分传输,编辑框仍然填写元。

本地完整运行的商品管理页:初始四件商品,每页三条,列表与分页来自真实接口。 本地完整运行的商品管理页:初始四件商品,每页三条,列表与分页来自真实接口。

本次验证使用 Node 24.19.0、Vue 3.5.44、Vite 8.3.5、Vue 插件 6.0.10 和 Express 5.3.0。下面把依赖写成确切版本;已有项目可以按自己的版本适配,不能直接拿 Vue 2 的构建方式混用。先执行 node --version,确认运行的是准备使用的 Node,而不是终端里另一份旧安装。

建立目录:

product-admin-demo/
  package.json
  index.html
  vite.config.js
  api.mjs
  src/
    main.js
    App.vue

先保存 package.json:

{
  "name": "product-admin-demo",
  "private": true,
  "type": "module",
  "scripts": {
    "api": "node api.mjs",
    "dev": "vite --host 127.0.0.1",
    "build": "vite build"
  },
  "dependencies": {
    "vue": "3.5.44",
    "express": "5.3.0"
  },
  "devDependencies": {
    "vite": "8.3.5",
    "@vitejs/plugin-vue": "6.0.10"
  }
}

在该目录执行:

npm install

成功后保留生成的 package-lock.json。另一台机器按同一份锁文件运行 npm ci,比重新解析一遍依赖更容易复现。这里只安装页面和本地接口需要的包,没有顺手加入状态库、路由和组件库。

接口先约定清楚,页面才知道何时成功

列表请求为 GET /api/products?keyword=键盘&page=1&pageSize=3,返回当前页 items 和筛选后的 total。新增使用 POST,修改使用 PUT 加商品 id,删除使用 DELETE。删除成功返回 204,没有 JSON 正文;客户端必须单独处理,不能对所有成功响应无条件执行 response.json()。

保存 api.mjs:

import express from 'express';
import { randomUUID } from 'node:crypto';
const app = express();
app.use(express.json({ limit: '16kb' }));
let products = [
  { id: 'p1', name: '机械键盘', priceCents: 23900, stock: 12 },
  { id: 'p2', name: '无线鼠标', priceCents: 8900, stock: 35 },
  { id: 'p3', name: '显示器支架', priceCents: 15900, stock: 8 },
  { id: 'p4', name: '键盘收纳袋', priceCents: 2900, stock: 20 }
];
function validProduct(body) {
  return body && typeof body.name === 'string'
    && body.name.trim().length >= 1 && body.name.trim().length <= 80
    && Number.isInteger(body.priceCents) && body.priceCents >= 0 && body.priceCents <= 99999999
    && Number.isInteger(body.stock) && body.stock >= 0 && body.stock <= 1000000;
}
app.get('/api/products', (req, res) => {
  const page = Number(req.query.page ?? 1), pageSize = Number(req.query.pageSize ?? 3);
  const keyword = String(req.query.keyword ?? '').trim().toLowerCase();
  if (!Number.isInteger(page) || page < 1 || !Number.isInteger(pageSize) || pageSize < 1 || pageSize > 50)
    return res.status(400).json({ message: '页码或每页数量不合法' });
  const matched = products.filter(p => p.name.toLowerCase().includes(keyword));
  res.json({ items: matched.slice((page - 1) * pageSize, page * pageSize), total: matched.length });
});
app.post('/api/products', (req, res) => {
  if (!validProduct(req.body)) return res.status(400).json({ message: '名称、价格或库存不合法' });
  const product = { id: randomUUID(), name: req.body.name.trim(), priceCents: req.body.priceCents, stock: req.body.stock };
  products.unshift(product);
  res.status(201).json(product);
});
app.put('/api/products/:id', (req, res) => {
  const index = products.findIndex(p => p.id === req.params.id);
  if (index < 0) return res.status(404).json({ message: '商品不存在' });
  if (!validProduct(req.body)) return res.status(400).json({ message: '名称、价格或库存不合法' });
  products[index] = { id: req.params.id, name: req.body.name.trim(), priceCents: req.body.priceCents, stock: req.body.stock };
  res.json(products[index]);
});
app.delete('/api/products/:id', (req, res) => {
  const index = products.findIndex(p => p.id === req.params.id);
  if (index < 0) return res.status(404).json({ message: '商品不存在' });
  products.splice(index, 1); res.status(204).end();
});
app.use((err, req, res, next) => {
  const status = err.type === 'entity.too.large' ? 413 : 400;
  res.status(status).json({ message: status === 413 ? '请求体过大' : 'JSON 格式不正确' });
});
app.listen(3401, '127.0.0.1', () => console.log('API http://127.0.0.1:3401'));

列表先筛选再分页,所以 total 是匹配商品总数。若先分页后搜索,搜出来的只会是当前页内部结果,页数也会错。接口同时限制页码和每页数量;前端控件限制不了手动发来的请求,名称、价格和库存仍由服务端验证。

这里用整数分表示金额,避免把商品价格当作任意浮点数来传。客户端将输入的两位小数拆成整数,服务端只接收范围内的整数分。此处没有优惠、税费或多币种规则;那些规则需要单独确定,不能在保存时随手加一个小数运算就当结算完成。

配好开发代理和页面入口

vite.config.js 让浏览器请求的 /api 转给 3401 端口。页面因此只使用相对地址,浏览器看到的仍是 Vite 的同一个来源。开发代理只在开发服务器中工作,npm run build 不会把它变成正式环境的网关;部署时仍需由 Nginx 或同源后端处理 /api。

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
  plugins: [vue()],
  server: { proxy: { '/api': 'http://127.0.0.1:3401' } }
});

入口 index.html:

<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>商品管理</title>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</html>

入口脚本 src/main.js:

import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');

不用从 CDN 加载 Vue,也不用直接双击 App.vue。单文件组件需要经过构建工具处理;浏览器应访问开发服务器给出的 HTTP 地址。

列表状态和编辑草稿分开,表单才好维护

将下面完整代码保存为 src/App.vue。它包含列表、分页、编辑区和必要样式,可以直接运行。

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const keyword = ref(''), page = ref(1), pageSize = 3;
const items = ref([]), total = ref(0), loading = ref(false), error = ref('');
const saving = ref(false), deleting = ref(false), formError = ref(''), notice = ref('');
const editingId = ref(null), formOpen = ref(false);
const draft = ref({ name: '', price: '', stock: 0 });
const pages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
let currentController, requestSequence = 0;
async function request(path, options = {}) {
  const response = await fetch('/api' + path, options);
  if (response.status === 204) return null;
  const data = await response.json();
  if (!response.ok) throw new Error(data.message || `请求失败:${response.status}`);
  return data;
}
async function load() {
  currentController?.abort();
  const controller = new AbortController(); currentController = controller;
  const sequence = ++requestSequence;
  loading.value = true; error.value = '';
  try {
    const query = new URLSearchParams({ keyword: keyword.value.trim(), page: String(page.value), pageSize: String(pageSize) });
    const data = await request('/products?' + query, { signal: controller.signal });
    if (sequence !== requestSequence) return;
    const lastPage = Math.max(1, Math.ceil(data.total / pageSize));
    if (page.value > lastPage) { page.value = lastPage; return await load(); }
    items.value = data.items; total.value = data.total;
  } catch (cause) {
    if (cause.name !== 'AbortError' && sequence === requestSequence) error.value = cause.message;
  } finally { if (sequence === requestSequence) loading.value = false; }
}
function search() { page.value = 1; load(); }
function changePage(next) { page.value = next; load(); }
function edit(product = null) {
  editingId.value = product?.id ?? null;
  draft.value = product
    ? { name: product.name, price: (product.priceCents / 100).toFixed(2), stock: product.stock }
    : { name: '', price: '', stock: 0 };
  formError.value = ''; notice.value = ''; formOpen.value = true;
}
function priceToCents(value) {
  const match = /^(\d{1,6})(?:\.(\d{1,2}))?$/.exec(String(value).trim());
  if (!match) throw new Error('价格需要填写非负金额,最多两位小数');
  return Number(match[1]) * 100 + Number((match[2] ?? '').padEnd(2, '0'));
}
async function save() {
  if (saving.value) return;
  formError.value = ''; saving.value = true;
  try {
    const body = { name: draft.value.name.trim(), priceCents: priceToCents(draft.value.price), stock: Number(draft.value.stock) };
    if (!body.name || body.name.length > 80 || !Number.isInteger(body.stock) || body.stock < 0 || body.stock > 1000000)
      throw new Error('请检查商品名称和非负整数库存');
    const id = editingId.value;
    await request(id ? '/products/' + encodeURIComponent(id) : '/products', {
      method: id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
    });
    formOpen.value = false; notice.value = id ? '修改成功' : '新增成功'; await load();
  } catch (cause) { formError.value = cause.message; }
  finally { saving.value = false; }
}
async function remove(product) {
  if (deleting.value || !window.confirm(`确定删除“${product.name}”?`)) return;
  deleting.value = true; error.value = ''; notice.value = '';
  try { await request('/products/' + encodeURIComponent(product.id), { method: 'DELETE' }); notice.value = '删除成功'; await load(); }
  catch (cause) { error.value = cause.message; }
  finally { deleting.value = false; }
}
onMounted(load);
onUnmounted(() => currentController?.abort());
</script>
<template>
  <main>
    <header><div><h1>商品管理</h1><p>搜索、分页和商品维护</p></div><button @click="edit()" :disabled="saving">新增商品</button></header>
    <form class="search" @submit.prevent="search"><input v-model="keyword" aria-label="商品关键字" placeholder="输入商品名称"><button>搜索</button></form>
    <p v-if="notice" role="status" class="success">{{ notice }}</p>
    <p v-if="error" role="alert" class="error">{{ error }} <button @click="load">重试</button></p>
    <p v-if="loading" role="status">正在加载商品……</p>
    <div v-else class="table-wrap">
      <table><thead><tr><th>商品</th><th>价格</th><th>库存</th><th>操作</th></tr></thead>
        <tbody><tr v-for="product in items" :key="product.id"><td>{{ product.name }}</td><td>¥{{ (product.priceCents / 100).toFixed(2) }}</td><td>{{ product.stock }}</td>
          <td><button @click="edit(product)" :disabled="saving">编辑</button> <button @click="remove(product)" :disabled="deleting || saving">删除</button></td>
        </tr><tr v-if="!items.length"><td colspan="4">没有匹配商品</td></tr></tbody>
      </table>
    </div>
    <nav aria-label="分页"><button @click="changePage(page - 1)" :disabled="page === 1 || loading">上一页</button><span>第 {{ page }} / {{ pages }} 页,共 {{ total }} 件</span><button @click="changePage(page + 1)" :disabled="page >= pages || loading">下一页</button></nav>
    <section v-if="formOpen" class="editor"><h2>{{ editingId ? '编辑商品' : '新增商品' }}</h2>
      <form @submit.prevent="save">
        <label>商品名称<input v-model="draft.name" required maxlength="80"></label>
        <label>价格(元)<input v-model="draft.price" inputmode="decimal" required></label>
        <label>库存<input v-model="draft.stock" type="number" min="0" max="1000000" step="1" required></label>
        <p v-if="formError" role="alert" class="error">{{ formError }}</p>
        <button :disabled="saving">{{ saving ? '保存中……' : '保存' }}</button> <button type="button" @click="formOpen = false" :disabled="saving">取消</button>
      </form>
    </section>
  </main>
</template>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #f1f5f9; color: #172554; font: 16px/1.6 system-ui, sans-serif; }
main { max-width: 1000px; margin: 28px auto; padding: 24px; background: white; border-radius: 14px; }
header, .search, nav { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
header { justify-content: space-between; } h1, h2, p { margin: 0 0 12px; }
button, input { font: inherit; padding: 8px 12px; } button { cursor: pointer; } button:disabled { cursor: default; opacity: .55; }
.table-wrap { overflow-x: auto; } table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { text-align: left; padding: 12px; border-bottom: 1px solid #dbe3ef; white-space: nowrap; }
nav { justify-content: center; } .editor { border: 1px solid #cbd5e1; border-radius: 10px; padding: 20px; margin-top: 24px; }
label { display: block; margin: 12px 0; } label input { display: block; width: min(100%, 420px); }
.error { color: #b91c1c; } .success { color: #166534; }
@media(max-width: 640px) { main { margin: 12px; padding: 16px; } .search input { width: 100%; } }
</style>

items 是最近一次查询成功的列表,draft 是编辑区独立的数据。点编辑时只复制所需字段,不把整行对象直接交给表单修改。这样用户改到一半点取消,列表里的旧商品仍然保持原样;只有接口保存成功后,重新查询得到的值才进入列表。

新增和编辑复用同一个表单,通过 editingId 选择 POST 或 PUT。打开表单时同时清空上一轮错误,避免上次保存失败的提示跟着新商品出现。保存成功才关闭编辑区;网络错误或服务端拒绝时,草稿保留,让用户可以修正后重试。

saving 在发请求前就设为 true,并在入口再次检查。禁用按钮改善界面反馈,入口检查则让同一页面保存过程不因连续触发而重复执行。它不是服务端幂等协议:多标签页、连接中断后的重试仍需后端按业务规则处理,不能把页面按钮置灰当成所有重复写入都已消失。

列表每次查询建立新的控制器和序号。新查询开始后取消旧请求,同时只允许当前序号采用结果或清除 loading。不能让较早一次请求的 finally 把较新查询的加载状态提前关掉。页面重点仍是商品管理,取消机制只是让真实搜索操作保持结果正确的一段实现。

按顺序启动,再用几次操作检查完整流程

保持两个终端都在项目目录。第一个终端:

npm run api

应输出 API http://127.0.0.1:3401。第二个终端:

npm run dev

访问终端显示的 Vite 地址。初始四件商品分两页,第一页三件。搜索“键盘”应剩下机械键盘和键盘收纳袋,总数二;清空关键字再次搜索,恢复四件。

新增一件“测试台灯”,价格填 59.90,库存填 6。保存后应出现“新增成功”,列表总数增加一,接口实际保存的 priceCents 为 5990。编辑这件商品,把库存改成零,保存后应仍然显示这件商品,而不是把零当成未填写。再打开编辑,修改名称后点取消,列表名称不变。

删除也要试一条容易漏的路径。先在 API 终端按 Ctrl+C 停止服务,再执行 npm run api,让内存数据恢复为初始四件;页面清空关键字,点搜索,然后进入第二页,删除唯一一条记录。删除成功后的重新查询发现当前页超出最后一页,先回到第一页再加载,分页文字应变为一页、总数三。不要直接把页码减一;如果当前页还有其他商品,减页会让用户突然跳走。

空结果是正常状态。搜索一个不存在的商品应显示“没有匹配商品”,而不是红色错误。接口失败则显示错误和重试按钮。两者分开,用户才知道应该调整条件还是稍后重试。

390像素手机视口中的真实页面;表格在自己的容器内横向滚动,页面本身不被撑宽。 390像素手机视口中的真实页面;表格在自己的容器内横向滚动,页面本身不被撑宽。

联调失败时,从请求本身往回查

页面空白时先看启动终端与浏览器控制台,确认 Vue 插件已安装、App.vue 路径大小写一致。页面出现但接口失败,检查 3401 服务是否启动,浏览器请求是否以 /api/products 开头,以及 Vite 代理目标是否和接口端口一致。

修改代理配置后重启 Vite。不要在前端请求里添加 Access-Control-Allow-Origin 试图修跨域;它属于服务端响应策略。这个项目通过开发代理让浏览器走同源路径,直接改为访问另一个端口时,就应重新处理真实跨域配置。

也可以绕过页面单独验证接口:

curl 'http://127.0.0.1:3401/api/products?page=1&pageSize=3'
curl -i -X POST 'http://127.0.0.1:3401/api/products' \
  -H 'Content-Type: application/json' \
  -d '{"name":"测试台灯","priceCents":5990,"stock":6}'
curl -i 'http://127.0.0.1:3401/api/products?page=0'

前两条分别得到列表和 201 新商品,最后一条返回 400。Windows PowerShell 可使用 curl.exe 避免命令别名差异,并按当前终端调整续行写法。

把价格改成三位小数,页面应提示最多两位小数,接口不会收到写请求。再用 curl 直接提交负库存,服务端也应拒绝,说明校验没有只放在页面。停止 API 服务后保存,错误会留在编辑区,刚才填写的内容仍在;重新启动 API 再保存,才会采用新结果。由于示例接口使用内存,重启前新增的数据会消失,这与保存请求失败是两种不同现象。

最后执行 npm run build,应生成 dist 目录。构建成功证明组件和资源能打包,尚不代表正式访问、代理和数据持久化已经具备。先把本地搜索到保存的这条流程完整验收,再接正式接口和部署环境,出问题时才有一份已知能工作的对照。

正文到此结束
评论插件初始化中...
Loading...