做校园公益捐赠网站这几年,我前后搭过好几个版本,从最早用PHP+Layui的老古董,到后来用Spring Boot+Vue的前后端分离,踩过的坑一个比一个经典。最近帮一个高校公益社团重新做了一个校园旧物爱心捐赠平台,这次选了Node.js+Vue这套组合,过程和结果都挺有代表性。趁着热乎,把整个设计和实现过程整理出来,项目代号就叫20of6,希望能给正在做类似毕设、课设或者真实校园项目的朋友一些参考。
先说结论:如果你要做的也是一个面向校园内部、以信息发布和捐赠流程管理为主的小型公益平台,Node.js+Vue这套技术栈非常合适。它的开发效率高、上手快,尤其适合一个人或两三个人搞定全栈的开发节奏。但要是你要做高并发、复杂事务的商用系统,那得另说。
1. 项目整体设计与技术选型
1.1 为什么选Node.js做后端
很多人在做校园类Web项目时,第一反应是Spring Boot或者Django。不是说那些不好,而是对于校园旧物捐赠这个场景,Node.js有它独特的优势。
旧物捐赠网站的核心业务并不复杂:用户注册登录、发布闲置物品、浏览搜索物品、提交捐赠申请、管理员审核、线下交接确认。这类业务的特点是IO密集型——大量读写请求、图片上传下载、状态查询,但计算量很小。这正是Node.js的舒适区,事件驱动、非阻塞IO,让它在处理这种轻量级业务时非常从容。
另外还有一个很现实的原因:前端用了Vue,整个项目都是JavaScript,前后端语言统一。这意味着你只需要一套技术栈就能打通全栈,不需要在Java和JavaScript之间来回切换思维模式。对于学生团队或者个人开发者,这种心智负担的降低是很实在的。
选型的时候我还考虑过Express和Koa,最终用了Express。原因很简单:Express生态最成熟,资料最多,遇到问题网上随便一搜就有答案。Koa更现代,支持async/await写起来更优雅,但社区资料相对少一些,对于这个项目规模,Express完全够用,而且招聘市场上会Express的人也更多。
1.2 Vue 2还是Vue 3:技术栈选择的纠结
说实话,Vue 2还是Vue 3,我犹豫了挺久。
Vue 3的Composition API确实更灵活,代码复用性更好,性能也更强。但有一个现实问题:校园公益社团那边有老成员用Vue 2写过一些组件,如果直接上Vue 3,这些老组件要重写。而且当时Element UI(Vue 2的经典组件库)比Element Plus(Vue 3的对应版本)稳定不少,遇到问题查起来更快。
最后我的方案是折中:主框架用Vue 3 + Element Plus + Vite,因为这是现在的主流方向,新写的代码、新的开发者都是基于这个。至于老组件,能重写的重写,不能重写的用兼容方案处理。
这个决定后来被证明是对的,Vite的冷启动速度和HMR热更新比Webpack快太多了,开发体验完全是两个层级。Vue 3的<script setup>语法写起来也干净,代码量比Options API少了差不多三分之一。
1.3 系统架构与功能模块划分
整个系统是标准的前后端分离架构:
code复制客户端(浏览器)→ Nginx → Vue前端静态资源
↓
Node.js API服务(Express)
↓
MySQL数据库 + 本地文件存储
功能模块拆成三类角色视角:
普通用户端:
- 注册登录(手机号+验证码,或者学号登录)
- 浏览/搜索闲置物品(按分类、按关键词、按最新发布时间)
- 物品详情页浏览(多图、详情描述、捐赠状态)
- 发布闲置物品(标题、描述、图片、分类、新旧程度)
- 管理我发布的物品(下架、编辑、查看申请记录)
- 提交捐赠申请、查看申请状态
- 收藏感兴趣但还没决定要的物品
- 个人中心(我的信息、我的物品、我的申请、我的收藏)
管理员端:
- 用户管理(禁言、封号、角色调整)
- 物品管理(审核、下架违规物品)
- 捐赠申请审核(确认捐赠时间、地点、状态流转)
- 数据统计(每日发布量、成交率、分类占比)
- 公告管理(发布系统公告)
系统层面:
- JWT身份认证(登录状态保持)
- 图片上传与访问(本地存储,Nginx代理)
- 统一异常处理与日志记录
- 接口权限控制(管理员接口与普通用户接口隔离)
这些模块看起来多,但拆到数据库表里其实很清晰,后面会详细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与项目脚手架搭建
2.1 Node.js安装与版本选择
这个环节看着简单,但真的很多人在这一步就卡住了,尤其是Windows用户。
Node.js安装我强烈建议不要装最新版,而是要装LTS(长期支持)版本。我当时用的是Node 18 LTS,选这个是因为Vite 4和Express 4完全兼容,而且node-sass这类老顽固依赖不需要重新编译。
去官网下载安装包,一路下一步就行。但安装完之后一定要验证:
bash复制node -v
npm -v
如果这两个命令能输出版本号,说明基本环境没问题。
这里有个非常关键的操作,一定要做:配置npm淘宝镜像。因为Node.js官方源在国内访问不稳定,下载依赖包时经常超时。配置方法:
bash复制npm config set registry https://registry.npmmirror.com
配置完之后,npm install的速度会快非常多。如果是公司或者实验室有私有npm源,也可以用,原理一样。这一步千万不要省,不然每次安装依赖都能等到怀疑人生。
2.2 npm脚本执行报错:Windows用户的噩梦
标题里提到的npm.ps1无法加载问题,我估计十个Windows开发者里至少有八个遇到过。这个问题的完整报错是这样的:
code复制npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
本质原因是Windows默认的PowerShell执行策略(Execution Policy)是Restricted,不允许运行任何.ps1脚本文件。npm的可执行文件理论上是npm.cmd(bash环境用的),但PowerShell会默认去加载npm.ps1,然后就被拦了。
两种解决方案:
方案一(推荐):以管理员身份打开PowerShell,执行:
powershell复制Set-ExecutionPolicy RemoteSigned
然后选Y确认。这个方法的作用是允许本地创建的脚本运行,但下载的脚本需要签名。重启终端后npm命令就正常了。
方案二:改让npm命令走cmd而不用PowerShell
在PowerShell里把所有npm命令改成npm.cmd,比如npm.cmd install,这样就不会去加载.ps1文件了。
方案一更彻底,处理一次就永远不用管了。当然如果你后面还要跑其他第三方.ps1脚本,这个策略也是统一的。
2.3 前端项目初始化:Vite比Vue CLI爽太多
Vue项目的脚手架工具有两个主流选择,一个是官方的create-vue(基于Vite),一个是Vue CLI(基于Webpack)。
我的建议是直接在Vite上初始化:
bash复制npm create vite@latest frontend -- --template vue
cd frontend
npm install
npm run dev
Vite默认就支持<script setup>语法,热更新速度几乎是秒级的。对比一下Vue CLI启动一个中型项目需要10秒以上,Vite基本在1秒内完成,这个效率提升是实实在在的。
初始化完之后装主流依赖:
bash复制# 路由
npm install vue-router@4
# 状态管理
npm install pinia
# UI组件库
npm install element-plus
npm install @element-plus/icons-vue
# HTTP请求库
npm install axios
# 后端开发辅助
npm install -D nodemon
2.4 后端项目初始化:Express的工程化配置
后端目录结构我当时是这样规划的:
code复制server/
├── app.js # 入口文件
├── config/
│ └── index.js # 配置文件(端口、数据库连接等)
├── routes/ # 路由定义
│ ├── user.js # 用户相关接口
│ ├── item.js # 物品相关接口
│ ├── donation.js # 捐赠申请相关接口
│ ├── admin.js # 管理员相关接口
│ └── upload.js # 图片上传接口
├── controllers/ # 业务逻辑层
├── middleware/ # 中间件(JWT验证、权限控制等)
├── models/ # 数据库模型
├── utils/ # 工具函数
└── public/uploads/ # 图片上传目录
在Express中,路由只负责接收请求和调用对应的控制器方法,具体的业务逻辑写在controller里,这样代码结构清晰,后续维护也比较方便。初始化Express项目:
bash复制mkdir server && cd server
npm init -y
npm install express mysql2 jsonwebtoken bcryptjs multer cors
用到的包和各自的职责:
- express:Web框架
- mysql2:MySQL驱动,支持Promise
- jsonwebtoken:生成和验证JWT令牌
- bcryptjs:密码加密(纯JavaScript实现,不需要编译)
- multer:文件上传中间件
- cors:解决跨域问题
3. 数据库设计与后端接口实现
3.1 数据表设计:五张核心表怎么规划
数据库设计是整个项目的基石,这一步没做好,后面写业务逻辑的时候会非常痛苦。我反复调整了几轮,最后核心表就五张:
用户表(users):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT | 主键,自增 |
| username | VARCHAR(50) | 用户名(学号) |
| password | VARCHAR(255) | 密码(bcrypt加密后) |
| nickname | VARCHAR(50) | 昵称 |
| avatar | VARCHAR(255) | 头像URL |
| phone | VARCHAR(20) | 手机号 |
| role | TINYINT | 角色(0普通用户 1管理员) |
| status | TINYINT | 状态(0正常 1禁用) |
| created_at | DATETIME | 注册时间 |
物品表(items):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT | 主键 |
| user_id | INT | 发布者ID |
| title | VARCHAR(100) | 标题 |
| description | TEXT | 详细描述 |
| category | VARCHAR(30) | 分类(教材/数码/生活用品/衣物等) |
| condition_level | TINYINT | 新旧程度 |
| images | TEXT | 图片URL列表,用逗号分隔 |
| status | TINYINT | 状态(0待审核 1审核通过 2已捐出 3下架) |
| view_count | INT | 浏览次数 |
| created_at | DATETIME | 发布时间 |
捐赠申请表(donations):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT | 主键 |
| item_id | INT | 物品ID |
| applicant_id | INT | 申请人ID |
| message | TEXT | 申请留言 |
| status | TINYINT | 状态(0待审核 1通过 2拒绝 3完成) |
| contact_time | DATETIME | 约定交接时间 |
| created_at | DATETIME | 申请时间 |
收藏表(favorites):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT | 主键 |
| user_id | INT | 用户ID |
| item_id | INT | 物品ID |
| created_at | DATETIME | 收藏时间 |
公告表(announcements):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INT | 主键 |
| title | VARCHAR(100) | 公告标题 |
| content | TEXT | 公告内容 |
| created_at | DATETIME | 发布时间 |
表结构设计时有一个细节特别需要注意:旧物图片表字段,我用的是TEXT类型存逗号分隔的图片路径,而不是单独建一张图片表。这个取舍在数据量不大(单条物品图片不超过9张)的时候是合理的,查询时少一次联表,性能反而好。如果以后数据量上来了,再考虑拆分成独立的图片表也不迟。
3.2 登录认证:JWT和加密那些事
用户密码存储肯定不能明文,我用的是bcryptjs做哈希加密。这个库的好处是每次生成的哈希值都带随机盐,即使两个用户密码相同,加密后的结果也不一样,从源头避免了彩虹表攻击。验证密码的代码逻辑如下:
javascript复制const bcrypt = require('bcryptjs');
// 注册时加密
const hashedPassword = await bcrypt.hash(password, 10);
// 登录时验证
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return res.status(401).json({ message: '用户名或密码错误' });
}
登录成功后,我会生成一个JWT令牌返回给前端:
javascript复制const jwt = require('jsonwebtoken');
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
前端把token存在localStorage里,每次请求时在请求头带上Authorization: Bearer <token>。后端写一个中间件统一做鉴权:
javascript复制const auth = (roles = []) => {
return (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ message: '未登录' });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
if (roles.length && !roles.includes(decoded.role)) {
return res.status(403).json({ message: '无权限' });
}
next();
} catch (err) {
return res.status(401).json({ message: '登录已过期' });
}
};
};
// 使用方式
router.post('/items', auth([0, 1]), itemController.createItem);
router.delete('/items/:id', auth([1]), itemController.deleteItem);
3.3 物品发布与图片上传:Multer的完整配置
物品发布是用户用得最多的功能,核心就是表单提交+图片上传。
图片上传我用Multer,配置时需要注意几点:
javascript复制const multer = require('multer');
const path = require('path');
const fs = require('fs');
// 确保上传目录存在
const uploadDir = path.join(__dirname, '../public/uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// 生成唯一文件名,避免中文名和重名问题
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, uniqueSuffix + ext);
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 单张图片最大5MB
fileFilter: (req, file, cb) => {
// 只允许常见图片格式
const allowedTypes = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedTypes.includes(ext)) {
cb(null, true);
} else {
cb(new Error('不支持的文件格式'));
}
}
});
// 路由:最多上传9张图片
router.post('/upload', auth(), upload.array('images', 9), uploadController.uploadImages);
文件命名用时间戳+随机数的组合,是为了彻底避免文件名冲突。很多新手喜欢直接用原文件名,一旦遇到重名文件就直接覆盖,这是典型的坑。
图片上传成功后会返回一个路径数组,前端把路径跟物品表单数据一起提交到后端,后端拼接好存储到items表的images字段里。
3.4 捐赠申请流程的状态机设计
捐赠申请是整个系统业务逻辑最复杂的部分,不只是简单的增删改查。我设计了一个状态机:
code复制用户申请(0待审核) → 管理员通过(1待交接) → 线下交接完成(3已完成)
→ 管理员拒绝(2已拒绝) → 流程终止
每个状态只能由特定角色触发:
- 用户只能发起申请,不能修改状态
- 管理员可以审核通过、拒绝、确认完成
- 用户取消申请:只能在待审核状态
这个状态流转在前端页面要严格控制按钮显示。比如状态为“待审核”时只显示“撤回申请”,状态为“待交接”时显示“确认已交接”,其他状态不显示任何操作按钮。
这里涉及一个隐藏逻辑:同一件物品可能收到多个捐赠申请,但只能有一个申请被通过。所以在用户提交申请前,我先查询该物品是否已有人申请通过(状态为1或3),如果有则直接拒绝新申请:
javascript复制const activeDonation = await db.query(
'SELECT id FROM donations WHERE item_id = ? AND status IN (1, 3)',
[itemId]
);
if (activeDonation.length > 0) {
return res.status(400).json({ message: '该物品已有申请正在处理中' });
}
等流程结束后,管理员再把物品状态改为“已捐出”,整个业务流程就闭环了。
4. 前端页面与核心交互实现
4.1 登录注册:双向绑定和路由守卫
登录注册页没什么特别的,无非是表单校验和API调用,但有两个点我会强调。
第一,密码强度校验要做在前端也要做在后端。前端校验只是为了快速反馈,后端校验才是真正防止弱密码入库的关键。前后端的校验规则必须一致,否则会出现前端说密码合格,后端却返回“密码不符合要求”这种割裂体验。
第二,路由守卫必须处理好。Vue Router提供了beforeEach导航守卫,我在里面做两件事:检查token是否存在,检查当前用户是否有权限访问管理员页面:
javascript复制router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token');
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } });
return;
}
if (to.meta.requiresAdmin && !isAdmin()) {
next({ path: '/' });
return;
}
next();
});
登录状态我用Pinia管理,存储用户信息(昵称、头像、角色),方便多个组件共享。这里要注意刷新页面后Pinia状态会丢失,所以应用初始化时要从localStorage重新拉取用户信息,或者提供一个/api/user/info借口。
4.2 物品列表与搜索:防抖和分页的实践
物品列表页是门户页面,交互细节直接决定用户体验。
搜索框用了防抖(debounce)处理。如果不做防抖,用户在输入框敲一个字就触发一次搜索请求,既浪费资源又导致体验卡顿。我的实现是这样的:
javascript复制import { ref, watch } from 'vue';
import { debounce } from 'lodash-es';
const keyword = ref('');
const fetchItems = debounce(async () => {
const { data } = await api.get('/items', {
params: { keyword: keyword.value, page, pageSize }
});
items.value = data.list;
total.value = data.total;
}, 300);
watch(keyword, fetchItems);
分页组件用Element Plus的el-pagination,注意要给后端传page和pageSize,并且后端要返回total总数。前后端的分页参数命名要统一,否则联调时全是小坑。
物品列表展示上,我用卡片网格布局,每张卡片显示图片、标题、分类、新旧程度。分类用标签展示,不同分类用不同颜色,用户一眼能识别。列表按发布时间倒序,并把“最新发布”作为默认排序规则。
4.3 物品详情页:图片预览、相似推荐和捐赠申请
详情页是整个项目信息密度最高的页面。
图片预览这一块,Element Plus也有el-image组件,内置缩略图和预览功能。配置起来很简单,效果也够用:
html复制<el-image
v-for="(img, index) in item.images"
:key="index"
:src="img"
:preview-src-list="item.images"
:initial-index="index"
fit="cover"
/>
详情页的信息结构我做了分组:左半部分是图片区,右半部分是核心信息区(标题、价格标签、发布者、发布时间、分类、新旧程度),下方是详细的描述文字,再往下是捐赠申请表单或申请状态。
申请按钮的逻辑要区分几种情况:
- 当前用户是物品发布者时,不显示申请按钮(不能申请自己的物品)
- 物品状态不为“审核通过”时,按钮置灰
- 用户已申请过,显示“已申请”状态
- 物品已有其他申请在处理,显示“已有申请”状态
这个逻辑看起来简单,但实际编码时每种情况都要判断,建议把逻辑封装成一个计算属性:
javascript复制const applyBtnState = computed(() => {
if (item.user_id === currentUser.id) return { disabled: true, text: '自己发布的物品' };
if (item.status !== 1) return { disabled: true, text: '不可申请' };
if (myApplication.value) return { disabled: true, text: myApplication.value.status === 0 ? '审核中' : '申请已提交' };
return { disabled: false, text: '申请捐赠' };
});
详情页还能顺手加个浏览计数器,每访问一次就调用后端接口把view_count加1。
4.4 管理员后台:数据概览和审核操作
管理员界面我单独做了布局,左侧导航栏,右侧内容区。核心功能模块有:
数据概览:用卡片展示总用户数、物品总数、今日发布数、待审核物品数。加上了一个简单的柱状图展示近7天的发布趋势,图表用ECharts,按需引入,别把整个包都打进来。
物品审核:表格列出所有待审核的物品,缩略图、标题、分类、发布者、发布时间。每条记录后面两个按钮:通过、拒绝。点通过直接调接口改状态,点拒绝弹窗让管理员填拒绝原因。
捐赠审核:同样用表格,需要展示发起申请的来源用户和物品信息,因为管理员需要判断申请理由是否合理。通过后管理员还可以设置约定的交接时间。
用户管理:展示所有注册用户,支持按学号或昵称搜索,可以禁用某个用户。禁用后该用户的登录态会立即失效,这里我在JWT中间件里加了一个逻辑:每次请求都会查一下用户状态,如果为禁用状态直接拒绝。
4.5 前后端联调:axios封装和跨域问题一次说清
联调时最常用的操作之一,就是在main.js里把axios挂到全局,配置统一的baseURL和请求拦截器:
javascript复制// src/utils/request.js
import axios from 'axios';
import { ElMessage } from 'element-plus';
import router from '../router';
const request = axios.create({
baseURL: '/api',
timeout: 10000
});
// 请求拦截器:自动携带token
request.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器:统一处理错误
request.interceptors.response.use(
response => response.data,
error => {
if (error.response?.status === 401) {
ElMessage.error('登录已过期,请重新登录');
localStorage.removeItem('token');
router.push('/login');
} else {
ElMessage.error(error.response?.data?.message || '网络错误');
}
return Promise.reject(error);
}
);
注意这里的baseURL我写的是/api,而不是完整的后端地址。配合Vite的代理配置,开发时浏览器和Node服务之间不存在跨域问题:
javascript复制// vite.config.js
export default defineConfig({
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})
生产环境则通过Nginx把/api前缀的请求转发到Node服务,前端静态文件直接由Nginx托管。这种模式前后端完全解耦,部署时也互不影响。
5. 常见问题与排查技巧实录
5.1 npm相关报错全解
这里借着标题和热搜词里反复出现的问题,直接整理一个速查表。这些报错我搞项目期间几乎都遇到了一遍,现在看到这些词已经形成条件反射了。
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
npm.ps1无法加载,禁止运行脚本 |
PowerShell执行策略限制 | Set-ExecutionPolicy RemoteSigned |
npm ERR! code ETIMEDOUT |
访问官方源超时 | 配置淘宝镜像 |
npm ERR! code EACCES |
全局安装权限不足 | 加sudo或以管理员身份运行 |
ERR! Can't find Python executable |
某些依赖需要编译原生模块 | 安装Python或换用纯JS替代包 |
npm install后有vulnerability告警 |
依赖包存在安全漏洞 | 运行npm audit fix修复 |
这里必须展开说一个容易忽略的点:如果项目是从别人的仓库拉下来的,最好删掉node_modules和package-lock.json重新安装。因为不同Node版本生成的依赖树可能不兼容,直接使用旧lock文件可能报各种诡异错误。自己重新安装虽然慢一点,但能规避很多不可排查的依赖问题。
5.2 后端启动失败:端口被占用与数据库连接失败
端口被占用是开发期最常踩的坑。启动Node服务时报Error: listen EADDRINUSE: address already in use :::3000,说明3000端口被别的进程占用了。
排查方式:
bash复制# 查看占用端口的进程
netstat -ano | findstr :3000
# 找到PID后强制结束
taskkill /PID 进程号 /F
还有一类坑是数据库连接问题。Mysql2报ER_ACCESS_DENIED_ERROR,十有八九是密码或用户名不对。第一次配置数据库连接时,我的建议是先用数据库客户端(Navicat、DBeaver等)手动验证一下账号密码能连通,再写进配置文件,排查起来会省很多事。
5.3 开发阶段必加的调试大法
我给这个项目加了一个简单的后端日志中间件,每次请求都打印方法和路径:
javascript复制app.use((req, res, next) => {
console.log(`[${new Date().toLocaleTimeString()}] ${req.method} ${req.url}`);
next();
});
这一点用处极大。前端一个请求过来,后端打不打印日志,立刻能判断问题是出在前端还是后端。如果后端有日志但没响应,就是后端逻辑的问题;如果后端连日志都没有,说明请求根本没到后端,那问题出在前端代理或网络层。
前端调试我一直用Vue Devtools插件,这个插件可以查看组件状态、Vue Router路由、Pinia状态,排查响应式数据问题几乎必备。不过这里要提醒一句,Vue 3对应的是Vue Devtools 6以上版本,旧版根本识别不到Vue 3的项目。浏览器装插件时注意装Chrome商店里最新的那个。
5.4 图片相关体验优化
图片上传和显示还有一个很容易被忽视的环节:体积。校园里拍的照片,手机原图动辄3-4MB,直接上传到服务器既占用带宽又拖慢加载速度。我前台上传前用Canvas做了一次压缩:
javascript复制function compressImage(file, maxWidth = 800, quality = 0.7) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = Math.min(1, maxWidth / img.width);
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => {
resolve(blob);
}, 'image/jpeg', quality);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
}
图片在前端压缩后再上传,把单张图从3MB压到100-200KB,不仅上传快,浏览加载也快很多。这个优化我强烈建议任何做Web项目的朋友都加上,成本极低,收益极明显。
5.5 缺少地图功能的旧物系统,还能怎么玩
标题里虽然没有明确提到地图,但实际上最初的设想是加一个校园内自助取货点地图功能。但后来想想,校园场景并不复杂,旧物交接通常就是约定在教学楼、宿舍楼下这些固定地点,一键复制地址比看地图更快。
配合热点词里提到的"vue播放m3u8"和"腾讯地图",我也想提一下:如果你后续有视频展示物品成色、或者做校内物品自提点导航的需求,技术方案是现成的。Vue 3里接入腾讯地图有官方组件,m3u8视频流可以用hls.js库播放。之前有一个版本我用hls.js做了物品短视频预览,效果非常惊艳。不过视频上传和转码比较重,校园公益捐赠这个场景是否必要就要权衡了。
6. 部署上线与性能优化建议
6.1 服务器部署到Nginx和PM2
开发完成后,部署阶段我用的方案是Nginx(托管前端静态文件)+ PM2(守护Node进程)。部署前先把前端打包:
bash复制npm run build
打包产物在dist目录,把整个dist目录传到服务器上,然后在Nginx配置:
nginx复制server {
listen 80;
server_name your-domain.com;
# 前端静态文件
root /var/www/donation-frontend/dist;
index index.html;
# 前端路由history模式配置
location / {
try_files $uri $uri/ /index.html;
}
# API反向代理
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 上传图片访问
location /uploads/ {
alias /var/www/donation-server/public/uploads/;
}
}
这里要注意try_files $uri $uri/ /index.html;这一行。Vue Router如果用的history模式,刷新非首页URL时会404,这个配置就是为了解决这个问题。如果不想折腾,也可以直接用hash模式,URL会带个#号,但部署省心。
后端服务用PM2启动:
bash复制npm install -g pm2
pm2 start app.js --name donation-server
pm2 save
pm2 startup
PM2的价值在于:进程崩溃自动重启、开机自启、输出日志到文件、直接查看性能指标。有了它,Node服务挂了也不慌,分分钟拉起来。
6.2 SQL注入与XSS防护
在做安全加固时,有两点值得写出来。
SQL注入:我一直用参数化查询(PreparedStatement),所以注入风险天然被堵住了。比如:
javascript复制// 错误示范:字符串拼接SQL
const sql = `SELECT * FROM items WHERE title LIKE '%${keyword}%'`;
// 正确示范:参数化查询
const sql = 'SELECT * FROM items WHERE title LIKE ?';
const params = [`%${keyword}%`];
这个习惯一定要养成,不光是项目中的数据安全,也是作为程序员的职业素养。
XSS:前端展示用户输入的描述文本时,用插值{{ }}而不是v-html。v-html一旦遇上恶意代码,浏览器直接执行,后果不堪设想。除非你明确知道内容是可信的,否则永远不要用v-html。
6.3 性能优化:懒加载与缓存策略
首屏加载速度对于用户体验影响巨大,Vue项目几个优化手段很有效:
路由懒加载:把每个页面组件单独打包,按需加载。配置方式就是在router里,把组件引入改成函数式:
javascript复制// 非懒加载
import Home from '../views/Home.vue';
// 懒加载
const Home = () => import('../views/Home.vue');
组件库按需引入:Element Plus如果全量引入,打包体积会多出几百KB。用vite-plugin-style-import做按需引入,哪个组件用到了才打包哪个。
图片懒加载:列表页的图片用v-lazy指令(例如vue-lazyload插件),页面滚动到图片位置时才发请求加载,首屏加载速度提升立竿见影。
这些优化做完,打包体积能减少大概一半,走一趟就能明显体会到效果。
写在最后:几个折腾出来的经验
做这类校园公益项目的过程中,我最大的体会是:技术选型不是越高新越好,而是越匹配业务场景越好。Node.js+Vue这个组合,在校园旧物捐赠这种中小型轻业务项目里,开发效率和后期维护成本的优势非常明显。一套JavaScript吃透全栈,团队协作不用在不同语言之间横跳,这样的便利不自己做一遍真的体会不到。
还有一个经验是把不该省的时间花在数据表设计上。我一开始认为表结构随便设计一下就能用,后面改起来麻烦的不仅是代码,还有已经录入的历史数据。字段的命名、类型、冗余设计,每一样都要考虑清楚再动手。趁早想清楚,后面省下的时间绝对超过预期。
最后分享一个小技巧:开发时把后端日志级别调到最详细。一开始可能觉得日志太多很吵,但真正遇到问题排查时,这些日志就是你调试的灯塔,能帮你快速定位到具体哪一行代码出了状况,能省下大把对着屏幕发呆的时间。
做旧物爱心捐赠网站,最有成就感的时候不是顺利完工的那一刻,而是上线的第一个星期,有同学真的通过这个平台拿到了自己需要的教材和日用百货,管理员那边的数据也在持续增长。这种项目,技术价值之外还多了一层社会价值,做起来还是很有动力的。
