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
Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐