今天跟大家分享node学习,利用Express框架来开发项目。
最近我用Express框架开发了一个二手交易平台。一共有两个端,一个端是管理后台端,一个是用户端。实现的功能有:
1、系统功能
(1)平台管理端
· 用户管理
· 商品分类管理
· 二手商品管理
· 订单管理
· 管理员管理
· 资讯管理
(2)用户端
· 二手商品展示
· 分类筛选
· 商品详情
· 订单凭证与我的订单
· 联系卖家
· 资讯查看
· 我的发布
node环境:16.20以上。
用户可以在手机端可以发布二手商品,在用户端页可以下单,因为是练习项目,就没法线上支付,只能上传凭证。然后可以通过管理后台进行二手交易平台管理。
部分代码:
bash
const AdminModel = require('../models/admin.model')
const JwtUtil = require('../utils/jwt')
const Response = require('../utils/response')
const AppError = require('../utils/appError')
const asyncHandler = require('../utils/asyncHandler')
class AdminController {
/**
* 管理员登录
*/
login = asyncHandler(async (req, res) => {
const { username, password } = req.body
const admin = await AdminModel.login(username, password)
if (!admin) {
return res.json(Response.error('用户名或密码错误'))
}
// 默认设置为超级管理员角色
const token = JwtUtil.generateToken({
id: admin.id,
username: admin.username,
role: 'admin', // 默认设置为admin角色
is_super: true // 默认设置为超级管理员
})
// 返回时过滤敏感信息
const { password: _, ...safeAdmin } = admin
res.json(Response.success({ token, admin: safeAdmin }, '登录成功'))
})
/**
* 获取所有管理员
*/
getAll = asyncHandler(async (req, res) => {
const admins = await AdminModel.getAll()
res.json(Response.success(admins))
})
/**
* 获取单个管理员
*/
getOne = asyncHandler(async (req, res) => {
const admin = await AdminModel.findById(req.params.id)
if (!admin) {
return res.json(Response.error('管理员不存在'))
}
res.json(Response.success(admin))
})
/**
* 创建管理员
*/
create = asyncHandler(async (req, res) => {
try {
const adminId = await AdminModel.create(req.body)
const admin = await AdminModel.findById(adminId)
res.json(Response.success(admin, '创建成功'))
} catch (error) {
res.json(Response.error(error.message))
}
})
/**
* 更新管理员
*/
update = asyncHandler(async (req, res) => {
try {
await AdminModel.update(req.params.id, req.body)
const admin = await AdminModel.findById(req.params.id)
res.json(Response.success(admin, '更新成功'))
} catch (error) {
res.json(Response.error(error.message))
}
})
/**
* 删除管理员
*/
delete = asyncHandler(async (req, res) => {
try {
await AdminModel.delete(req.params.id)
res.json(Response.success(null, '删除成功'))
} catch (error) {
res.json(Response.error(error.message))
}
})
}
module.exports = new AdminController()
bash
const db = require('../utils/database')
const { AppError } = require('../utils/error-handler')
const crypto = require('crypto')
const DateFormat = require('../utils/dateFormat')
class AdminModel {
static hashPassword(password) {
return crypto.createHash('md5').update(password).digest('hex')
}
static async login(username, password) {
const hashedPassword = this.hashPassword(password)
const sql = 'SELECT id, username, nickname, is_super, created_time, updated_time FROM admin WHERE username = ? AND password = ?'
const result = await db.query(sql, [username, hashedPassword])
return result[0] ? DateFormat.formatDBData(result[0]) : null
}
static async findById(id) {
const sql = 'SELECT id, username, nickname, is_super, created_time, updated_time FROM admin WHERE id = ?'
const result = await db.query(sql, [id])
return result[0] ? DateFormat.formatDBData(result[0]) : null
}
static async findByUsername(username) {
const sql = 'SELECT id, username, nickname, is_super, created_time, updated_time FROM admin WHERE username = ?'
const result = await db.query(sql, [username])
return result[0] ? DateFormat.formatDBData(result[0]) : null
}
static async getAll() {
const sql = 'SELECT id, username, nickname, is_super, created_time, updated_time FROM admin'
const result = await db.query(sql)
return DateFormat.formatDBData(result)
}
static async create(adminData) {
const { username, password, nickname } = adminData
// 检查用户名是否已存在
const existingAdmin = await this.findByUsername(username)
if (existingAdmin) {
throw new AppError('用户名已存在', 400)
}
const hashedPassword = this.hashPassword(password)
const currentTime = new Date()
const sql = 'INSERT INTO admin (username, password, nickname, created_time) VALUES (?, ?, ?, ?)'
const result = await db.query(sql, [username, hashedPassword, nickname, currentTime])
return result.insertId
}
static async update(id, adminData) {
const { nickname, password } = adminData
// 检查管理员是否存在
const admin = await this.findById(id)
if (!admin) {
throw new AppError('管理员不存在', 404)
}
// 不允许修改超级管理员
if (admin.is_super) {
throw new AppError('不能修改超级管理员信息', 403)
}
let sql = 'UPDATE admin SET '
const params = []
const updates = []
if (nickname) {
updates.push('nickname = ?')
params.push(nickname)
}
if (password) {
updates.push('password = ?')
params.push(this.hashPassword(password))
}
if (updates.length === 0) {
throw new AppError('没有要更新的数据', 400)
}
sql += updates.join(', ')
sql += ' WHERE id = ?'
params.push(id)
await db.query(sql, params)
return await this.findById(id)
}
static async delete(id) {
// 检查管理员是否存在
const admin = await this.findById(id)
if (!admin) {
throw new AppError('管理员不存在', 404)
}
// 不允许删除超级管理员
if (admin.is_super) {
throw new AppError('不能删除超级管理员', 403)
}
const sql = 'DELETE FROM admin WHERE id = ?'
await db.query(sql, [id])
return true
}
}
module.exports = AdminModel

bash
<template>
<div class="news-management">
<!-- 搜索区域 -->
<div class="search-container">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="标题">
<el-input v-model="searchForm.title" placeholder="请输入标题关键词" clearable></el-input>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="正常" :value="1"></el-option>
<el-option label="禁用" :value="0"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 操作区域 -->
<div class="operation-container">
<el-button type="primary" @click="handleAdd">新增资讯</el-button>
</div>
<!-- 表格区域 -->
<el-table
v-loading="tableLoading"
:data="newsList"
border
style="width: 100%"
>
<el-table-column prop="id" label="ID" align="center"></el-table-column>
<el-table-column prop="titles" label="标题" show-overflow-tooltip></el-table-column>
<el-table-column label="发布时间" align="center">
<template slot-scope="scope">
{{ formatDateTime(scope.row.publish_time) }}
</template>
</el-table-column>
<el-table-column label="封面图" align="center">
<template slot-scope="scope">
<el-image
v-if="scope.row.cover_image"
:src="scope.row.imageUrl || ''"
style="width: 80px; height: 60px"
fit="cover"
>
<div slot="error" class="image-error">
<i class="el-icon-picture-outline"></i>
</div>
</el-image>
<span v-else>无封面图</span>
</template>
</el-table-column>
<el-table-column label="状态" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'info'">
{{ scope.row.status === 1 ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="标签" align="center">
<template slot-scope="scope">
<el-tag
v-for="(tag, index) in getTagsArray(scope.row.tags)"
:key="index"
size="small"
style="margin-right: 5px; margin-bottom: 5px"
>
{{ tag }}
</el-tag>
<span v-if="!getTagsArray(scope.row.tags).length">无标签</span>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center">
<template slot-scope="scope">
{{ scope.row.created_time }}
</template>
</el-table-column>
<el-table-column label="操作" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button type="text" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页区域 -->
<div class="pagination-container">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.page"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
>
</el-pagination>
</div>
<!-- 新增/编辑资讯对话框 -->
<el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="70%">
<el-form :model="newsForm" :rules="rules" ref="newsForm" label-width="100px">
<el-form-item label="标题" prop="titles">
<el-input v-model="newsForm.titles" placeholder="请输入资讯标题"></el-input>
</el-form-item>
<el-form-item label="发布时间" prop="publish_time">
<el-date-picker
v-model="newsForm.publish_time"
type="datetime"
placeholder="选择发布时间"
value-format="yyyy-MM-dd HH:mm:ss"
></el-date-picker>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="newsForm.status">
<el-radio :label="1">正常</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="封面图" prop="cover_image">
<file-upload
v-model="newsForm.cover_image"
list-type="picture-card"
:accept="'.jpg,.jpeg,.png,.gif'"
:limit="1"
tip="只能上传jpg/png/gif文件,且不超过10MB"
>
<i class="el-icon-plus"></i>
</file-upload>
</el-form-item>
<el-form-item label="正文图" prop="content_image">
<file-upload
v-model="newsForm.content_image"
list-type="picture-card"
:accept="'.jpg,.jpeg,.png,.gif'"
:limit="1"
tip="只能上传jpg/png/gif文件,且不超过10MB"
>
<i class="el-icon-plus"></i>
</file-upload>
</el-form-item>
<el-form-item label="标签" prop="tags">
<el-tag
:key="tag"
v-for="tag in dynamicTags"
closable
:disable-transitions="false"
@close="handleTagClose(tag)">
{{tag}}
</el-tag>
<el-input
class="input-new-tag"
v-if="inputVisible"
v-model="inputValue"
ref="saveTagInput"
size="small"
@keyup.enter.native="handleInputConfirm"
@blur="handleInputConfirm"
>
</el-input>
<el-button v-else class="button-new-tag" size="small" @click="showInput">+ 添加标签</el-button>
</el-form-item>
<el-form-item label="内容" prop="contents">
<el-input
type="textarea"
:rows="6"
placeholder="请输入资讯内容"
v-model="newsForm.contents">
</el-input>
</el-form-item>
<el-form-item label="备注" prop="remarks">
<el-input
type="textarea"
:rows="3"
placeholder="请输入备注信息"
v-model="newsForm.remarks">
</el-input>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogVisible = false">取 消</el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading">确 定</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getNewsList, getNewsDetail, createNews, updateNews, deleteNews } from '@/api/news'
import { getFileById } from '@/api/file'
import FileUpload from '@/components/FileUpload'
export default {
name: 'NewsManagement',
components: {
FileUpload
},
data() {
return {
// 搜索表单
searchForm: {
title: '',
status: ''
},
// 表格加载状态
tableLoading: false,
// 资讯列表数据
newsList: [],
// 分页信息
pagination: {
page: 1,
pageSize: 10,
total: 0
},
// 对话框标题
dialogTitle: '新增资讯',
// 对话框可见性
dialogVisible: false,
// 提交按钮加载状态
submitLoading: false,
// 资讯表单
newsForm: {
titles: '',
publish_time: '',
contents: '',
status: 1,
remarks: '',
cover_image: '',
content_image: '',
tags: ''
},
// 表单验证规则
rules: {
titles: [
{ required: true, message: '请输入资讯标题', trigger: 'blur' },
{ min: 2, max: 100, message: '长度在 2 到 100 个字符', trigger: 'blur' }
],
publish_time: [
{ required: true, message: '请选择发布时间', trigger: 'change' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
],
contents: [
{ required: true, message: '请输入资讯内容', trigger: 'blur' }
]
},
// 标签相关
dynamicTags: [],
inputVisible: false,
inputValue: ''
}
},
created() {
this.fetchNewsList()
},
methods: {
// 获取资讯列表
async fetchNewsList() {
this.tableLoading = true
try {
const res = await getNewsList({
page: this.pagination.page,
pageSize: this.pagination.pageSize,
title: this.searchForm.title,
status: this.searchForm.status
})
if (res.code === 1000) {
// 处理图片URL
for (const item of res.data.list) {
if (item.cover_image) {
try {
const fileId = JSON.parse(item.cover_image)[0];
const fileRes = await getFileById(fileId);
if (fileRes.code === 1000 && fileRes.data) {
item.imageUrl = fileRes.data.full_url || fileRes.data.url;
}
} catch (err) {
console.error('处理图片URL失败:', err);
}
}
}
this.newsList = res.data.list
this.pagination.total = res.data.total
} else {
this.$message.error(res.msg || '获取资讯列表失败')
}
} catch (error) {
console.error('获取资讯列表失败:', error)
this.$message.error('获取资讯列表失败')
} finally {
this.tableLoading = false
}
},
// 搜索
handleSearch() {
this.pagination.page = 1
this.fetchNewsList()
},
// 重置搜索
resetSearch() {
this.searchForm = {
title: '',
status: ''
}
this.handleSearch()
},
// 每页条数变化
handleSizeChange(val) {
this.pagination.pageSize = val
this.fetchNewsList()
},
// 当前页变化
handleCurrentChange(val) {
this.pagination.page = val
this.fetchNewsList()
},
// 新增资讯
handleAdd() {
this.dialogTitle = '新增资讯'
this.newsForm = {
titles: '',
publish_time: new Date().toISOString().slice(0, 19).replace('T', ' '),
contents: '',
status: 1,
remarks: '',
cover_image: '',
content_image: '',
tags: ''
}
this.dynamicTags = []
this.dialogVisible = true
// 重置表单验证
this.$nextTick(() => {
this.$refs.newsForm && this.$refs.newsForm.clearValidate()
})
},
// 编辑资讯
async handleEdit(row) {
this.dialogTitle = '编辑资讯'
this.dialogVisible = true
try {
const res = await getNewsDetail(row.id)
if (res.code === 1000) {
if(res.data.cover_image){
res.data.cover_image = JSON.parse(res.data.cover_image);
}
if(res.data.content_image){
res.data.content_image = JSON.parse(res.data.content_image);
}
this.newsForm = { ...res.data }
// 处理标签
this.dynamicTags = this.getTagsArray(res.data.tags)
} else {
this.$message.error(res.msg || '获取资讯详情失败')
}
} catch (error) {
console.error('获取资讯详情失败:', error)
this.$message.error('获取资讯详情失败')
}
// 重置表单验证
this.$nextTick(() => {
this.$refs.newsForm && this.$refs.newsForm.clearValidate()
})
},
// 删除资讯
handleDelete(row) {
this.$confirm('确认删除该资讯吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteNews(row.id)
if (res.code === 1000) {
this.$message.success('删除成功')
this.fetchNewsList()
} else {
this.$message.error(res.msg || '删除失败')
}
} catch (error) {
console.error('删除资讯失败:', error)
this.$message.error('删除失败')
}
}).catch(() => {})
},
// 提交表单
submitForm() {
this.$refs.newsForm.validate(async valid => {
if (!valid) return
this.submitLoading = true
// 处理标签
this.newsForm.tags = JSON.stringify(this.dynamicTags)
try {
let res
if (this.newsForm.id) {
// 编辑
res = await updateNews(this.newsForm.id, this.newsForm)
} else {
// 新增
res = await createNews(this.newsForm)
}
if (res.code === 1000) {
this.$message.success(res.msg || '操作成功')
this.dialogVisible = false
this.fetchNewsList()
} else {
this.$message.error(res.msg || '操作失败')
}
} catch (error) {
console.error('提交资讯表单失败:', error)
this.$message.error('操作失败')
} finally {
this.submitLoading = false
}
})
},
// 获取图片URL
async getImageUrl(fileId) {
if (!fileId) return ''
const fileIdArray = JSON.parse(fileId);
try {
const res = await getFileById(fileIdArray[0])
if (res.code === 1000 && res.data) {
return res.data.full_url || res.data.url
}
return ''
} catch (error) {
console.error('获取图片URL失败:', error)
return ''
}
},
// 格式化日期时间
formatDateTime(dateTime) {
if (!dateTime) return '';
try {
const date = new Date(dateTime);
if (isNaN(date.getTime())) return dateTime;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
} catch (error) {
console.error('格式化日期时间失败:', error);
return dateTime;
}
},
// 解析标签JSON
getTagsArray(tags) {
if (!tags) return []
try {
if (typeof tags === 'string') {
return JSON.parse(tags)
}
return Array.isArray(tags) ? tags : []
} catch (error) {
console.error('解析标签失败:', error)
return []
}
},
// 标签相关方法
handleTagClose(tag) {
this.dynamicTags.splice(this.dynamicTags.indexOf(tag), 1)
},
showInput() {
this.inputVisible = true
this.$nextTick(_ => {
this.$refs.saveTagInput.$refs.input.focus()
})
},
handleInputConfirm() {
const inputValue = this.inputValue
if (inputValue && this.dynamicTags.indexOf(inputValue) === -1) {
this.dynamicTags.push(inputValue)
}
this.inputVisible = false
this.inputValue = ''
}
}
}
</script>
<style scoped>
.news-management {
background: white;
border-radius: 8px;
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-header h2 {
margin: 0 0 10px 0;
color: #333;
}
.page-header p {
margin: 0;
color: #666;
}
.search-container {
margin-bottom: 20px;
}
.operation-container {
margin-bottom: 20px;
display: flex;
justify-content: flex-end;
}
.pagination-container {
margin-top: 20px;
text-align: right;
}
.image-error {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: #f5f7fa;
color: #909399;
font-size: 20px;
}
.el-tag + .el-tag {
margin-left: 10px;
}
.button-new-tag {
margin-left: 10px;
height: 32px;
line-height: 30px;
padding-top: 0;
padding-bottom: 0;
}
.input-new-tag {
width: 90px;
margin-left: 10px;
vertical-align: bottom;
}
</style>
代码慢慢写的多了,然后对语法就更熟悉了。
管理端截图:
用户端: