
简介基于H5的PHPVue超市商品管理系统是一份面向毕业设计、课程设计以及中小型Web项目开发的学习型资源包适合计算机专业学生、PHP/Vue技术初学者以及需要快速完成管理类系统的工程师解决从系统设计、前后端实现到论文撰写和答辩演示的全流程问题。压缩包共283个文件、约18.5MB其中包含38个Vue组件、16个JS脚本、14个PHP后端文件、SQL数据库脚本、SVG/PNG静态资源、doc/docx设计文档、mp4演示视频以及bat一键部署脚本目录结构清晰便于快速启动和二次开发。已有41人浏览学习。系统覆盖首页、个人中心、商品分类、商品信息、上架与下架、销售信息、管理员管理等常用模块演示视频完整展示操作流程设计文档对需求分析、数据库设计和模块实现做了说明能帮助读者理解H5VuePHPMySQL前后端分离项目的落地方法也为类似电商系统开发提供参考。1. 基于H5的PHPVue超市商品管理系统这套技术组合解决什么问题给中小超市做商品管理最常见的落地形态不是重客户端而是“手机浏览器或 App 内嵌 H5 页面 一个数据接口层”。基于 H5 的 PHPVue 超市商品管理系统说的正是这条技术线Vue 负责页面渲染和交互PHP 提供 JSON 接口MySQL 落表存储商品、分类、库存和操作员账号。标题里的源码包只是交付物真正值得拆开看的是三层的边界——哪些表必须拆、接口要返回什么结构、H5 页面怎样在手机端保持一致体验。这篇文章不写论文怎么排版直接按可复现的顺序过一遍数据库建表、PHP 接口、Vue 页面、联调部署和常见报错。新手照着能复现一份可演示的完整项目有经验的人可以直接抄表结构和接口约定做二次开发。2. 超市商品管理系统的数据库设计先拆表再写SQL四种核心业务实体一次建完2.1 系统模块拆解从功能列表到表关系超市商品管理系统的功能边界一般是后台登录、商品分类维护、商品增删改查、商品图片上传、库存调整。按“一个模块至少一张表、业务动作单独记流水”的原则最小的表结构是四张admin_user 用户表、category 分类表、goods 商品表、stock_log 库存变动记录表。不要试图把所有信息塞进一张表比如把分类名直接冗余到商品表里短期查询省一次 JOIN一旦分类改名就要连带更新所有商品维护成本反而更高。字段类型的选择也需要在建模阶段定死价格用 decimal(10,2) 而不用 float避免金额出现 1.20000001 这类浮点误差库存用 int 而不用 varchar因为后续要支撑加减运算和排序图片字段存相对路径而不是 base64 字符串否则数据库体积会随着商品数量快速膨胀。create_time 和 update_time 统一用 int 时间戳而不是 datetimePHP 端格式化更可控也方便计算时间差。表名核心字段主要用途admin_userid, username, password, real_name, last_login_time后台登录密码用 password_hash 存摘要categoryid, name, sort, status商品分类sort 控制列表排序goodsid, category_id, name, goods_sn, price, stock, image, status商品主体价格与库存的权威来源stock_logid, goods_id, change_type, quantity, operator, remark库存变动流水用于对账和追责这四张表建完已经能支撑商品列表、分类筛选、上下架、库存台账这些核心功能。订单、促销、会员属于业务扩展不建议在初次建模时就混入商品表。商品管理系统的核心关注点永远只有三个商品有没有、库存对不对、成本和售价是否匹配。2.2 建表 SQL分类、商品、用户、库存流水一次建完按演示项目最常见的路径——登录后进入商品列表、新增商品、调整库存——给出可直接执行的建表脚本。引擎用 InnoDB字符集用 utf8mb4避免生僻商品名和 emoji 出现乱码。CREATE DATABASE IF NOT EXISTS supermarket DEFAULT CHARSET utf8mb4; USE supermarket; -- 后台管理员表 CREATE TABLE admin_user ( id int(11) unsigned NOT NULL AUTO_INCREMENT, username varchar(30) NOT NULL DEFAULT COMMENT 登录名, password varchar(255) NOT NULL DEFAULT COMMENT password_hash 结果, real_name varchar(30) NOT NULL DEFAULT COMMENT 姓名, last_login_time int(11) NOT NULL DEFAULT 0 COMMENT 最近登录时间戳, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT后台管理员表; -- 商品分类表 CREATE TABLE category ( id int(11) unsigned NOT NULL AUTO_INCREMENT, name varchar(30) NOT NULL DEFAULT COMMENT 分类名, sort int(11) NOT NULL DEFAULT 0 COMMENT 排序值越小越靠前, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1启用 0停用, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品分类表; -- 商品表 CREATE TABLE goods ( id int(11) unsigned NOT NULL AUTO_INCREMENT, category_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT 分类ID, name varchar(120) NOT NULL DEFAULT COMMENT 商品名称, goods_sn varchar(40) NOT NULL DEFAULT COMMENT 唯一货号, price decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 零售价, stock int(11) NOT NULL DEFAULT 0 COMMENT 当前库存, image varchar(255) NOT NULL DEFAULT COMMENT 商品图相对路径, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1上架 0下架, create_time int(11) NOT NULL DEFAULT 0, update_time int(11) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_goods_sn (goods_sn), KEY idx_category (category_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品表; -- 库存变动流水表 CREATE TABLE stock_log ( id int(11) unsigned NOT NULL AUTO_INCREMENT, goods_id int(11) unsigned NOT NULL DEFAULT 0 COMMENT 商品ID, change_type tinyint(1) NOT NULL DEFAULT 1 COMMENT 1入库 2出库 3盘点调整, quantity int(11) NOT NULL DEFAULT 0 COMMENT 变动数正负号表示增减, operator varchar(30) NOT NULL DEFAULT COMMENT 操作人, remark varchar(255) NOT NULL DEFAULT COMMENT 备注, create_time int(11) NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_goods_time (goods_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT库存变动记录表;goods_sn 建议作为唯一键并手动生成格式类似 SP 加时间戳加四位随机数或直接用日期加当日序号方便后续对接条码。直接拿自增 id 当货号的问题在于一旦和其他渠道数据合并就会冲突。category_id 只加普通索引不加外键约束因为外键会给 PHP 端的批量导入和清库操作带来顺序约束而 InnoDB 的外键在分库分表场景中往往是最先被去掉的。status 用 tinyint(1) 而不是定义一组字符串枚举值也是同理后续要扩展成“预售、售罄、下架”三种状态时改动成本最低。库存调整必须成对出现改 goods 表的 stock 字段同时插入一条 stock_log。stock_log 的 quantity 用正负号区分入库和出库便于对账时直接 SUM。如果只改库存不记录流水月底盘点时根本无法判断是操作失误还是数据写入遗漏。3. PHP后端实现用PDO封装数据接口给Vue提供稳定的JSON3.1 接口路径规划每个入口对应一个业务动作常见做法是把 PHP 按“一个接口一个文件”的方式组织不引入完整框架也能保持清晰。登录状态用 token 而不是 session因为 H5 页面可能嵌在 WebView 里session 依赖 Cookie在部分内嵌场景下会被禁用。登录成功后后端生成 token前端放在请求头里回传PHP 端每次请求校验。接口方法输入输出/api/login.phpPOSTusername, passwordtoken 与用户信息/api/goods_list.phpGETpage, page_size, keyword, category_id商品列表与 total/api/goods_create.phpPOSTname, price, stock, category_id, image新增记录 id/api/stock_change.phpPOSTgoods_id, change_type, quantity, remark更新后的库存/api/upload.phpPOSTfile上传后的图片 URL所有接口返回统一结构code 为 0 表示成功非 0 表示业务错误data 放实际数据。很多新手在出错时用 http_response_code(500) 返回一段 HTML 文本Vue 端 axios 拿到后直接抛异常页面表现为白屏所以接口统一输出格式比具体字段更重要。登录接口、商品接口、上传接口都共用同一套跨域头处理 php 跨域是一次性的公共成本。3.2 数据库连接与跨域处理先做好公共部分每个请求都初始化一次 PDO 连接是浪费常见做法是单例保存 PDO 实例把配置独立放在 config 文件里避免账号密码混进业务代码后随 git 提交泄露。?php // core/Db.php class Db { private static ?PDO $pdo null; public static function pdo(): PDO { if (self::$pdo null) { $conf require __DIR__ . /../config/database.php; $dsn sprintf(mysql:host%s;port%d;dbname%s;charset%s, $conf[host], $conf[port], $conf[dbname], $conf[charset]); self::$pdo new PDO($dsn, $conf[user], $conf[pass], [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, ]); } return self::$pdo; } }把 ATTR_ERRMODE 设为异常模式SQL 写错时能直接看到报错位置默认取关联数组json_encode 时数组 key 就是字段名。接下来每个 api 文件开头统一处理跨域Access-Control-Allow-Origin 不写*而是动态回显请求来源域名同时配上 Allow-Credentials这样开发环境与线上域名不一样时不会互相干扰header(Content-Type: application/json; charsetutf-8); $origin $_SERVER[HTTP_ORIGIN] ?? ; header(Access-Control-Allow-Origin: . $origin); header(Access-Control-Allow-Credentials: true); header(Access-Control-Allow-Headers: Content-Type, X-Token); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(204); exit; }OPTIONS 请求是浏览器跨域预检自动发出的PHP 端必须在业务逻辑之前拦截并直接返回 204否则后续接口会重复执行两次Vue 里表现为“请求发了两遍”。3.3 商品列表与新增接口分页、条件过滤与参数约束列表接口是演示视频里必有的场景。分页不用 SQL_CALC_FOUND_ROWS而是分两次执行 COUNT 和 SELECT可读性更好MySQL 8.0 也已经把 SQL_CALC_FOUND_ROWS 标记为弃用。?php // api/goods_list.php require_once __DIR__ . /../core/Db.php; $page max(1, (int)($_GET[page] ?? 1)); $pageSize min(50, max(1, (int)($_GET[page_size] ?? 10))); $keyword trim((string)($_GET[keyword] ?? )); $categoryId (int)($_GET[category_id] ?? 0); $whereArr [g.status 1]; $params []; if ($keyword ! ) { $whereArr[] (g.name LIKE :kw OR g.goods_sn LIKE :kw); $params[:kw] % . $keyword . %; } if ($categoryId 0) { $whereArr[] g.category_id :cid; $params[:cid] $categoryId; } $whereSql implode( AND , $whereArr); $countStmt Db::pdo()-prepare(SELECT COUNT(*) FROM goods g WHERE {$whereSql}); $countStmt-execute($params); $total (int)$countStmt-fetchColumn(); $offset ($page - 1) * $pageSize; $listSql SELECT g.id, g.goods_sn, g.name, g.price, g.stock, g.image, c.name AS category_name FROM goods g LEFT JOIN category c ON c.id g.category_id WHERE {$whereSql} ORDER BY g.id DESC LIMIT {$offset}, {$pageSize}; $listStmt Db::pdo()-prepare($listSql); $listStmt-execute($params); $list $listStmt-fetchAll(); echo json_encode([ code 0, msg ok, data [list $list, total $total], ], JSON_UNESCAPED_UNICODE);LIMIT 后面的变量直接用 intval 强转后拼接是安全的因为值已经被限制成整数真正危险的是 keyword 和 category_id必须走 prepare 绑定防止经 LIKE 或数值参数注入 SQL。返回结构里带 total前端才能判断分页底部是否显示“没有更多了”。LEFT JOIN 一次性带出分类名前端不需要再发一次分类接口做数组组装。新增接口走 POST JSON body。Vue 的 axios 默认用 application/json 发送请求体PHP 端要用 file_get_contents(php://input) 读取而不是 $_POST?php // api/goods_create.php $input json_decode(file_get_contents(php://input), true); if (!is_array($input)) { $input $_POST; } $name trim((string)($input[name] ?? )); $price (float)($input[price] ?? 0); $stock (int)($input[stock] ?? 0); $categoryId (int)($input[category_id] ?? 0); $image trim((string)($input[image] ?? )); if ($name || $price 0) { echo json_encode([code 422, msg 商品名称和价格不能为空价格必须大于0]); exit; } $sn SP . date(YmdHis) . mt_rand(1000, 9999); $stmt Db::pdo()-prepare( INSERT INTO goods (category_id, name, goods_sn, price, stock, image, create_time, update_time) VALUES (:cid, :name, :sn, :price, :stock, :image, :ct, :ct) ); $stmt-execute([ :cid $categoryId, :name $name, :sn $sn, :price $price, :stock $stock, :image $image, :ct time(), ]); echo json_encode([code 0, msg ok, data [id (int)Db::pdo()-lastInsertId()]]);参数校验这里故意只做了最少的检查真实项目还要校验分类是否存在、库存是否为非负整数、商品名长度是否超长。前端再怎么拦截后端都要重新校验一遍因为用 curl 直接打接口可以绕过任何页面校验。图片上传属于商品管理不可缺失的一环H5 页面通过input typefile acceptimage/*拍照选图后直接 POST 到 upload.php?php // api/upload.php if (empty($_FILES[file])) { echo json_encode([code 422, msg 缺少 file 字段]); exit; } $file $_FILES[file]; $ext strtolower(pathinfo($file[name], PATHINFO_EXTENSION)); $allow [jpg, jpeg, png, gif, webp]; if (!in_array($ext, $allow, true)) { echo json_encode([code 422, msg 图片类型不支持]); exit; } $dir __DIR__ . /../uploads/; $relDir date(Ymd); if (!is_dir($dir . $relDir)) { mkdir($dir . $relDir, 0755, true); } $filename $relDir . / . uniqid(, true) . . . $ext; if (!move_uploaded_file($file[tmp_name], $dir . $filename)) { echo json_encode([code 500, msg 保存失败检查 uploads 目录权限]); exit; } echo json_encode([ code 0, msg ok, data [url /uploads/ . $filename] ], JSON_UNESCAPED_UNICODE);上传接口要防两类问题一是文件类型只靠扩展名白名单挡不住改后缀的脚本生产环境应再用 getimagesize 或 finfo 做二次校验二是目录权限uploads 目录需要给 PHP-FPM 运行用户写权限否则 move_uploaded_file 返回 false页面表现是选择图片后没有反应。文件重命名用 uniqid 生成新名字避免用户上传“测试图.jpg”与已存在的文件冲突。4. VueH5前端搭建从路由配置到商品管理页面4.1 项目初始化与路由划分hash 模式在 H5 里更省心Vue 侧用 Vite 创建项目是目前的标配之后安装 vue-router 和 axios。命令如下npm create vitelatest supermarket-admin -- --template vue cd supermarket-admin npm install npm install vue-router axios创建完成后src 目录按 views、router、api、utils 四个子目录拆分。路由配置里用 meta.public 标记无需登录的页面配合全局守卫判断 token// src/router/index.js import { createRouter, createWebHashHistory } from vue-router; const routes [ { path: /login, component: () import(../views/Login.vue), meta: { public: true } }, { path: /, redirect: /goods }, { path: /goods, component: () import(../views/GoodsList.vue) }, { path: /goods/form, component: () import(../views/GoodsForm.vue) }, ]; const router createRouter({ history: createWebHashHistory(), routes, }); router.beforeEach((to) { const token sessionStorage.getItem(token); if (!to.meta.public !token) { return { path: /login, query: { redirect: to.fullPath } }; } return true; }); export default router;这里特意用 createWebHashHistory 而不是 createWebHistory。history 模式要求服务器把所有路径都 rewrite 到 index.html否则刷新就 404hash 模式天然兼容 Nginx 静态托管和 App 内嵌 WebView避免 vue 打包后布局异常这类问题。登录成功的 token 写入 sessionStorage有效期由 PHP 在登录接口返回前端只负责保存和回传不要在前端代码里写死过期时间。4.2 axios 请求层封装baseURL、token 注入和错误码统一处理在 Vue 项目里直接每个组件写 axios.get 会让接口地址和 token 注入重复很多遍。常见做法是封装一个 request 实例业务组件只关心 data 部分// src/api/request.js import axios from axios; const request axios.create({ baseURL: /api, timeout: 10000, }); request.interceptors.request.use((config) { const token sessionStorage.getItem(token); if (token) { config.headers[X-Token] token; } return config; }); request.interceptors.response.use( (response) { const res response.data; if (res.code ! 0) { return Promise.reject(new Error(res.msg || 请求失败)); } return res.data; }, (error) { if (error.response error.response.status 401) { sessionStorage.removeItem(token); window.location.href #/login; } return Promise.reject(error); } ); export default request;baseURL 写成/api而不是完整的http://127.0.0.1:8080/api是因为开发环境可以用 Vite 的 proxy 把 /api 转发到 PHP 地址生产环境由 Nginx 把 /api 交给 PHP-FPM前端代码本身不需要任何修改。这样微信 H5 页面在域名切换时也能复用同一套构建产物。响应拦截器把业务 code 和 HTTP 状态分开处理非 0 的 code 直接 reject组件里只捕获异常并提示逻辑更干净。4.3 商品列表页与新增表单v-model、v-for 和路由参数要保持同步商品列表页对 H5 很典型顶部搜索框、中间商品卡片列表、底部新增按钮。以 vant 组件库为例模板这样组织template div classgoods-wrap div classsearch-bar input v-modelkeyword placeholder商品名 / 货号 keyup.enterrefreshList / button clickrefreshList搜索/button /div van-list v-model:loadingloading :finishedfinished finished-text没有更多了 loadloadList div classgoods-item v-foritem in list :keyitem.id clickgoDetail(item) img :srcitem.image classgoods-img / div classgoods-info div classgoods-name{{ item.name }}/div div classgoods-meta{{ item.category_name }} · 库存 {{ item.stock }}/div div classgoods-price¥ {{ item.price }}/div /div /div /van-list /div /templatevant 对 H5 的适配比较完整van-list 内置滚动加载免去自己监听 scroll 的麻烦。若不引组件库也可以自己用 IntersectionObserver 监听列表最后一项进入视口时再加载下一页。列表搜索词建议同步到路由 query例如 URL 变为/#/goods?keyword牛奶从商品详情返回列表时刷新页面才能恢复搜索状态这比把状态只放在组件内更可靠。const keyword ref(); const page ref(1); const list ref([]); const finished ref(false); const loading ref(false); async function loadList() { const data await request.get(/goods_list.php, { params: { page: page.value, page_size: 10, keyword: keyword.value } }); list.value.push(...data.list); if (list.value.length data.total) { finished.value true; } else { page.value 1; } } function refreshList() { list.value []; page.value 1; finished.value false; loadList(); }新增商品表单与列表最大的区别是提交时的数据处理。H5 端图片上传推荐“先传图拿 URL、再提交表单”这样弱网环境下图片上传失败可以单独重试不会连带整张表单报错async function handleUpload(event) { const file event.target.files[0]; const formData new FormData(); formData.append(file, file); const data await request.post(/upload.php, formData); form.image data.url; }axios 在传 FormData 时不用手写 Content-Type浏览器会自动带上 boundary如果手动设置为 multipart/form-data反而会因为缺少 boundary 导致服务端收不到文件。4.4 H5 适配细节 viewport、图片比例和输入框遮挡H5 页面在手机浏览器与桌面端共用一套页面时先确认 index.html 的 viewport meta 正确否则移动端打开后会按 980px 宽渲染表现为字小、点击错位meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno /禁止缩放是为了防止输入框聚焦时页面布局跳动。商品图片用固定宽高加 object-fit: cover避免不同商家上传的图片比例把卡片撑乱。键盘弹起遮住输入框是 App 内嵌 H5 页面最常见的问题处理方法是给表单区域加 overflow-y: auto让滚动容器在键盘弹出后可以自动上移。问题表现处理未设置 viewport移动端字小、点击错位加上 viewport meta图片比例不统一卡片高度跳动img 设固定宽高object-fit: cover键盘遮挡输入框光标看不见滚动容器加 overflow-y: auto5. 构建与部署Vue产物和PHP放到同一Web根目录5.1 构建 Vue 产物并配置 Nginx开发环境用 Vite proxy 转发 /api生产环境则把 Vue 构建出的 dist 目录与 PHP 业务代码放在同一个 Web 根目录下由 Nginx 同时托管静态资源和 PHP 接口。npm run build构建完成后dist 目录内是 index.html 和静态资源将这些文件放到 /var/www/supermarket 目录PHP 接口文件放在 /var/www/supermarket/api 下uploads 放在同级目录形成可直接演示的目录结构。Nginx 配置参考server { listen 80; server_name admin.example.com; root /var/www/supermarket; location / { try_files $uri $uri/ /index.html; } location ~ \.php$ { include fastcgi_params; fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location /uploads/ { expires 1d; } }try_files 是给 history 路由用的hash 模式下保留也无害关键的是 location ~ .php$ 必须把 SCRIPT_FILENAME 指向真实存在的 PHP 文件路径否则 PHP-FPM 会报 No input file specified。uploads 目录单独设置缓存策略图片请求不进入 PHP 处理减少不必要的进程占用。5.2 交付前做一次快速验证拿到源码包先别急着翻论文文档按下面顺序跑一遍能确认系统是否真的可运行curl -X POST http://localhost/api/login.php \ -H Content-Type: application/json \ -d {username:admin,password:123456}返回 code 为 0 并带 token说明 PHP 和 MySQL 链路通。接着访问商品列表接口确认 JSON 结构包含 list 和 totalVue 页面才能正常渲染。再打开网页执行一次新增商品和上传图片确认 uploads 目录有写入权限。提示curl 排查时先看返回的 HTTP 状态码再看 JSON 内容接口没通之前不要盲目调前端页面能省下一半联调时间。405 错多半是接口路径不符500 错优先看 PHP 错误日志。本文还有配套的精品资源点击获取