node.js+Vue3.js实现记账本功能
accounting项目是一个记账项目
项目gitHub地址:
前端:前端地址
后端:后端地址
功能分为:登录注册,解析微信导出的订单并展示,上传账单到数据库进行存储,获取并查看账单,查看汇总情况等功能,目前文件导入只支持微信账单下载后导入,其他的需要冲洗配置,在文件导入模块中。其他账单数据建议单条导入并上传,但是后台对于数据去重(当导入的数据在数据库中存在时,后端回进行筛选,但是筛选逻辑是通过商户订单实现的,目前没找到合适的筛选字段,需后续优化,所以单条上传存在问题就是可能回重复上传,需要确定筛选字段。
后端使用nodeJs编写,使用express.js实现,使用多个框架,不一一列举了下载项目查看package.json可以查看
登录注册功能
没什么特别的,只有使用cavans实现前端的验证码功能,再组件中随机生成一个整数,这个整数对应26个英文字母,并将这个字符串验证码传递给父组件,及登录注册页面进行前端验证码验证,如果验证码不正确是无法登录或者注册的
验证码验证功能实现
// setup语法
import { onMounted, onBeforeUnmount, computed, getCurrentInstance } from 'vue';
const { proxy } = getCurrentInstance();
let props = defineProps({
canvasWidth: { // 容器宽度
type: Number,
default: 100
},
canvasHeight: { // 容器高度
type: Number,
default: 40
},
refreshTime: {//验证码刷新间隔时间
type: Number,
default: 60
}
}),
emits = defineEmits(['getVerifyCodeStr']),
verifyCodeTimeId = null,//定时器id
randomStr = '0123456789abcdefghijklmnopqrstuvwxyz',// 所有随机字符串
trueRefreshTime = computed(() => {
return props.refreshTime * 1000;
}),
verifyCodeStr = '';//验证码字符串
onMounted(() => {
initVerifyCodeImg();
});
// 初始化
const initVerifyCodeImg = () => {
drawPic();
verifyCodeTimeId = setInterval(() => {
drawPic();
}, trueRefreshTime.value);
};
// 绘制验证码图片
const drawPic = () => {
randomCode();
let canvas = document.getElementById('canvasDom')
let ctx = canvas.getContext('2d')
ctx.textBaseline = 'bottom'
// 绘制背景
ctx.fillStyle = '#e6ecfd'
ctx.fillRect(0, 0, props.canvasWidth, props.canvasHeight)
// 绘制文字
for (let i = 0; i < verifyCodeStr.length; i++) {
drawText(ctx, verifyCodeStr[i], i)
}
drawLine(ctx)
drawDot(ctx)
};
//4个随机字符
const randomCode = () => {
verifyCodeStr = ''
for (let i = 0; i < 4; i++) {
let txt = randomStr[randomNum(0, randomStr.length)];
verifyCodeStr += txt;
}
emits('getVerifyCodeStr', verifyCodeStr);
};
// 随机数
const randomNum = (min, max) => {
return Math.floor(Math.random() * (max - min) + min)
};
// 随机色
const randomColor = (min, max) => {
let r = randomNum(min, max)
let g = randomNum(min, max)
let b = randomNum(min, max)
return 'rgb(' + r + ',' + g + ',' + b + ')'
};
// 绘制文字
const drawText = (ctx, txt, i) => {
let fontSizeMin = props.canvasHeight - 10,// 字体最小值
fontSizeMax = props.canvasHeight;// 字体最大值
ctx.fillStyle = randomColor(50, 160) // 随机生成字体颜色
ctx.font = randomNum(fontSizeMin, fontSizeMax) + 'px SimHei' // 随机生成字体大小
let x = (i + 1) * (props.canvasWidth / (verifyCodeStr.length + 1))
let y = randomNum(fontSizeMax, props.canvasHeight - 10 )
var deg = randomNum(-30, 30)
// 修改坐标原点和旋转角度
ctx.translate(x, y)
ctx.rotate(deg * Math.PI / 180)
ctx.fillText(txt, 0, 0)
// 恢复坐标原点和旋转角度
ctx.rotate(-deg * Math.PI / 180)
ctx.translate(-x, -y)
};
// 绘制干扰线
const drawLine = (ctx) => {
for (let i = 0; i < 4; i++) {
ctx.strokeStyle = randomColor(100, 200)
ctx.beginPath()
ctx.moveTo(randomNum(0, props.canvasWidth), randomNum(0, props.canvasHeight))
ctx.lineTo(randomNum(0, props.canvasWidth), randomNum(0, props.canvasHeight))
ctx.stroke()
}
};
// 绘制干扰点
const drawDot = (ctx) => {
// 绘制干扰点
for (let i = 0; i < 30; i++) {
ctx.fillStyle = randomColor(0, 255)
ctx.beginPath()
ctx.arc(randomNum(0, props.canvasWidth), randomNum(0, props.canvasHeight), 1, 0, 2 * Math.PI)
ctx.fill()
}
};
//暴露绘制图片方法供父组件使用
defineExpose({
drawPic
});
onBeforeUnmount(() => {
clearInterval(verifyCodeTimeId);//页面销毁前,需要清除定时器
});
<canvas id="canvasDom" :width="props.canvasWidth" :height="props.canvasHeight" @click="drawPic"></canvas>
#canvasDom {
cursor: pointer;
}
解析微信导出的订单并展示
解析使用了xlsx的解析包,再excel中前17行是微信的信息,上传到数据库的意义不大,所以就没展示,后续把信息展示提供出来,目前还没实现
解析代码:
const reader = new FileReader();
reader.onload = (e) => {
try {
let jsonData = [];
if (file.name.endsWith('.csv')) {
// 处理 CSV 文件
const csvData = e.target.result;
const workbook = XLSX.read(csvData, { type: 'binary', raw: true });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
jsonData = XLSX.utils.sheet_to_json(worksheet, { raw: true,
headers: true,
range: 16
});
} else {
// 处理 Excel 文件
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
jsonData = XLSX.utils.sheet_to_json(worksheet, { raw: true,
});
}
excelData.value = [];
let data = []
for(let i = 0; i < jsonData.length; i++){
let item = {}
excelColumns.value.forEach((column) => {
const cellValue = jsonData[i][column.label];
// 如果是时间字段,并且是数字(Excel 日期)
if (column.prop === 'time' && typeof cellValue === 'number') {
// 解析 Excel 日期
const dateObj = XLSX.SSF.parse_date_code(cellValue);
const jsDate = new Date(
dateObj.y, // 年
dateObj.m - 1, // 月(JS 月份是 0-11)
dateObj.d, // 日
dateObj.H, // 时
dateObj.M, // 分
dateObj.S // 秒
);
// 格式化为 "yyyy/mm/dd hh:mm:ss"
item[column.prop] = jsDate.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false, // 24 小时制
}).replace(/\//g, '/').replace(/,/g, '');
} else {
item[column.prop] = cellValue;
}
});
data.push({
uid: user.getUser().uid,
...item
});
}
for(let i = 0; i < data.length; i++){
console.log(data[i]);
if(data[i].revOrExp === '/' && data[i].static === "提现已到账"){
data[i].revOrExp = '1'
}else if(data[i].revOrExp === '/' && data[i].static === "充值完成"){
data[i].revOrExp = '0'
}
data[i].static = '已完成'
}
excelData.value = data;
total.value = jsonData.length;
tableData.value = excelData.value.slice(0, size.value);
ElMessage.success(`成功解析 ${jsonData.length} 条数据`);
} catch (error) {
ElMessage.error('文件解析失败: ' + error.message);
console.error('文件解析错误:', error);
}
};
if (file.name.endsWith('.csv')) {
reader.readAsBinaryString(file.raw);
} else {
reader.readAsArrayBuffer(file.raw);
}
上传账单功能
账单上传分为两种:批量上传和单条上传
批量上传时后端会做处理,当上传的账单的商单后会去重在进行插入,目前导入格式只能是微信导出的账单类型
单条上传会需要输入不能为空的字段不输入无法新增,因为在批量上传是使用的是商户单号进行的上传,所以最好不要单条上传微信支付的账单,除非你记得商户单号
项目使用pinia状态管理工具
使用插件pinia-plugin-persistedstate进行状态持久化
主要封装1个模块
注册pinia工具
//store/index.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia
注册user模块
// store/models/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
const userStore = defineStore(
'user',
() => {
let user = ref(null)
const setUser = (data) => {
user.value = data
}
const removeUser = () => {
user.value = null
}
let token = ref(null)
const setToken = (data) => {
token.value = data
localStorage.setItem('token', data)
}
const getUser = () => {
return user.value
}
const removeToken = () => {
token.value = null
localStorage.removeItem('token')
}
const login = (data) => {
setUser(data)
setToken(data.token)
}
const logout = () => {
removeUser()
removeToken()
}
return { user, setUser, removeUser, token, setToken, removeToken, login, logout, getUser }
},
{
persist: true,// 使用pinia-plugin-persistedstate插件
},
)
export default userStore
项目目录结构
controllers — 控制器
routes — 路由定义
models — 数据模型
middleware — 自定义中间件
services — 业务逻辑
public — 静态资源
app.js — 主应用文件
项目代码
1、jwt代码
import jwt from 'jsonwebtoken';
// 加载dotenv模块,用于读取.env文件中的环境变量
import dotenv from 'dotenv';
dotenv.config();
const secret = process.env.JWT_SECRET;
export default {
sign: (payload) => jwt.sign(payload, secret, { expiresIn: '24h' }),
verify: (token) => jwt.verify(token, secret)
};
2、连接数据库MySQL(如果使用其他数据库可以上网查阅连接方式
import mysql from 'mysql2/promise';
// 创建基础连接池
const basePool = mysql.createPool({
host: 'localhost',
user: 'root',
password: 'root',
database: 'accounting',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// 包装query方法以添加日志
const pool = {
async query(sql, params) {
console.log(`[SQL执行] ${sql}`);
if (params) console.log(`[参数] ${JSON.stringify(params)}`);
return basePool.query(sql, params);
},
async execute(sql, params) {
console.log(`[SQL执行] ${sql}`);
if (params) console.log(`[参数] ${JSON.stringify(params)}`);
return basePool.execute(sql, params);
},
getConnection: basePool.getConnection.bind(basePool),
end: basePool.end.bind(basePool)
};
export default pool;
3、中间件
①jwt验证中间件
import jwt from '../config/jwt.js';
import { asyncHandler } from '../utils/responseHandler.js';
export const authMiddleware = async (req, res, next) => {
const token = req.headers.authorization;
if (!token) {
const err = new Error('未提供token');
err.status = 401;
throw err;
}
try {
const decoded = jwt.verify(token);
req.user = decoded;
next();
} catch (error) {
const err = new Error('无效的token');
err.status = 401;
throw err;
}
}
②文件上传中间件
import multer from 'multer';
import path from 'path';
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, 'public/uploads/');
},
filename: (req, file, cb) => {
const customName = req.body.fileName ? req.body.fileName.replace(/[^\w\d-]/g, '') : file.fieldname;
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, `${customName}-${uniqueSuffix}${path.extname(file.originalname)}`);
}
});
const fileFilter = (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf', 'text/plain'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('文件类型不支持'), false);
}
};
export default multer({
storage,
fileFilter,
limits: { fileSize: 10 * 1024 * 1024 } // 10MB
});
③路由打印中间件
// middleware/routeLogger.js
/**
* 路由日志中间件
* @param {Object} req - Express请求对象
* @param {Object} res - Express响应对象
* @param {Function} next - 下一个中间件函数
*/
const routeLogger = (req, res, next) => {
const method = req.method;
const path = req.path;
const timestamp = new Date().toLocaleString();
console.log(`[${timestamp}] 方法: ${method} 访问路径: ${path}`);
// 新增SQL日志监听
const originalQuery = res.query;
res.query = function(sql, params) {
console.log(`[SQL] ${sql}`);
if (params) console.log(`[参数] ${JSON.stringify(params)}`);
return originalQuery.apply(this, arguments);
};
next();
};
export default routeLogger;
4、app.js
// 加载express模块,用于创建服务器
import express from 'express';
const app = express();
const PORT = 3000
// 引入路由中间件
import routeLogger from './middleware/routerLogger.js';
import cors from 'cors';
// 引入路由
import router from './routes/api.js'
// 使用中间件
app.use(routeLogger);
app.use(cors());
app.use(express.json({ limit: '50mb' }));// 解析json字符串
app.use(express.urlencoded({ extended: true, limit: '50mb' }));// 解析urlencoded字符串
// 配置静态资源访问
app.use('/uploads', express.static('public/uploads'));
app.get('/', (req, res) => {
res.send('Hello World');
});
app.use('/api', router);
app.listen(PORT, () => {
console.log(`服务器端口运行为:http://localhost:${PORT}`);
});
运行方式
1、安装依赖
npm install
2、新建.env文件,用于jwt生成token的环境变量
.env文件内容
JWT_SECRET = "509e56deb8ebaed89090c8aedba4985c31ac30612d63545d9998318095ce05b9"
需要使用JWT_SECRET全局变量,来生成JWT,和验证JWT所以做好使用node生成一串随机数用做参数
3、运行项目
npm start
更多推荐
所有评论(0)