feat: 小菜记账 v1.0 - 完整功能实现

核心功能:
- 记账CRUD(支出/收入/分类/备注/日期)
- 统计分析(概览/分类占比/每日趋势)
- 预算管理(按月设置/进度条/超支提醒)
- 数据导出CSV

安全与认证:
- HMAC-SHA256签名token认证
- 用户数据隔离
- 输入验证与错误处理
- CORS配置

前端优化:
- 骨架屏加载
- 账单按日期分组
- 预算页面重构(快捷预设+Numpad)
- SvgIcon组件(H5+微信双端适配)
- 下拉刷新

后端优化:
- 共享日期工具函数
- 数据库连接池优化
- 健康检查端点
- 优雅关闭处理

技术栈:
- 前端:Uni-app (Vue 3 + Pinia)
- 后端:Node.js + Express + MySQL
This commit is contained in:
2026-05-29 16:14:15 +08:00
commit c7f29aa7a7
70 changed files with 32282 additions and 0 deletions

238
scripts/gen-icons.js Normal file
View File

@@ -0,0 +1,238 @@
/**
* 生成 Tab Bar 图标 PNG 文件 (81x81)
* 使用 Node.js 内置模块,无需安装额外依赖
*/
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const SIZE = 81
const OUT_DIR = path.join(__dirname, '../client/src/static/icons')
// 颜色
const INACTIVE = [191, 179, 179] // #BFB3B3
const ACTIVE = [255, 140, 105] // #FF8C69
const TRANSPARENT = [0, 0, 0, 0]
function createPNG(pixels) {
// PNG signature
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
// IHDR chunk
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(SIZE, 0)
ihdr.writeUInt32BE(SIZE, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // color type: RGBA
ihdr[10] = 0 // compression
ihdr[11] = 0 // filter
ihdr[12] = 0 // interlace
// Raw image data: filter byte (0) + RGBA pixels per row
const rawData = Buffer.alloc(SIZE * (1 + SIZE * 4))
for (let y = 0; y < SIZE; y++) {
const rowOffset = y * (1 + SIZE * 4)
rawData[rowOffset] = 0 // no filter
for (let x = 0; x < SIZE; x++) {
const srcIdx = (y * SIZE + x) * 4
const dstIdx = rowOffset + 1 + x * 4
rawData[dstIdx] = pixels[srcIdx]
rawData[dstIdx + 1] = pixels[srcIdx + 1]
rawData[dstIdx + 2] = pixels[srcIdx + 2]
rawData[dstIdx + 3] = pixels[srcIdx + 3]
}
}
const compressed = zlib.deflateSync(rawData)
function makeChunk(type, data) {
const typeBuffer = Buffer.from(type, 'ascii')
const length = Buffer.alloc(4)
length.writeUInt32BE(data.length, 0)
const combined = Buffer.concat([typeBuffer, data])
const crc = crc32(combined)
return Buffer.concat([length, combined, crc])
}
const ihdrChunk = makeChunk('IHDR', ihdr)
const idatChunk = makeChunk('IDAT', compressed)
const iendChunk = makeChunk('IEND', Buffer.alloc(0))
return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk])
}
// CRC32 table
const crcTable = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)
}
crcTable[n] = c
}
function crc32(buf) {
let crc = 0xFFFFFFFF
for (let i = 0; i < buf.length; i++) {
crc = crcTable[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8)
}
const result = Buffer.alloc(4)
result.writeUInt32BE((crc ^ 0xFFFFFFFF) >>> 0, 0)
return result
}
function makePixels(drawFn, color) {
const pixels = new Uint8Array(SIZE * SIZE * 4)
// Fill transparent
for (let i = 0; i < pixels.length; i += 4) {
pixels[i + 3] = 0
}
drawFn(pixels, color)
return pixels
}
function setPixel(pixels, x, y, r, g, b, a = 255) {
if (x < 0 || x >= SIZE || y < 0 || y >= SIZE) return
const idx = (y * SIZE + x) * 4
// Alpha blending
const srcA = a / 255
const dstA = pixels[idx + 3] / 255
const outA = srcA + dstA * (1 - srcA)
if (outA > 0) {
pixels[idx] = Math.round((r * srcA + pixels[idx] * dstA * (1 - srcA)) / outA)
pixels[idx + 1] = Math.round((g * srcA + pixels[idx + 1] * dstA * (1 - srcA)) / outA)
pixels[idx + 2] = Math.round((b * srcA + pixels[idx + 2] * dstA * (1 - srcA)) / outA)
pixels[idx + 3] = Math.round(outA * 255)
}
}
function drawLine(pixels, x0, y0, x1, y1, r, g, b, thickness = 2) {
const dx = Math.abs(x1 - x0)
const dy = Math.abs(y1 - y0)
const sx = x0 < x1 ? 1 : -1
const sy = y0 < y1 ? 1 : -1
let err = dx - dy
while (true) {
for (let tx = -thickness; tx <= thickness; tx++) {
for (let ty = -thickness; ty <= thickness; ty++) {
if (tx * tx + ty * ty <= thickness * thickness) {
setPixel(pixels, x0 + tx, y0 + ty, r, g, b)
}
}
}
if (x0 === x1 && y0 === y1) break
const e2 = 2 * err
if (e2 > -dy) { err -= dy; x0 += sx }
if (e2 < dx) { err += dx; y0 += sy }
}
}
function drawCircle(pixels, cx, cy, radius, r, g, b, filled = true) {
for (let y = -radius; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
const dist = Math.sqrt(x * x + y * y)
if (filled ? dist <= radius : (dist >= radius - 1.5 && dist <= radius + 0.5)) {
setPixel(pixels, cx + x, cy + y, r, g, b)
}
}
}
}
function drawRect(pixels, x, y, w, h, r, g, b) {
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
setPixel(pixels, x + dx, y + dy, r, g, b)
}
}
}
// === Icon drawing functions ===
function drawHome(pixels, color) {
const [r, g, b] = color
const cx = 40, cy = 40
// House body (rectangle)
drawRect(pixels, 24, 38, 34, 26, r, g, b)
// Roof (triangle)
for (let i = 0; i < 20; i++) {
drawLine(pixels, cx, 18, cx - 20 + i * 2, 38, r, g, b, 1)
}
// Door
drawRect(pixels, 35, 50, 12, 14, 255, 248, 240)
}
function drawChart(pixels, color) {
const [r, g, b] = color
// Three bars
drawRect(pixels, 18, 45, 12, 20, r, g, b)
drawRect(pixels, 35, 30, 12, 35, r, g, b)
drawRect(pixels, 52, 20, 12, 45, r, g, b)
// Base line
drawRect(pixels, 14, 65, 54, 2, r, g, b)
}
function drawBill(pixels, color) {
const [r, g, b] = color
// Document shape
drawRect(pixels, 22, 14, 38, 54, r, g, b)
// Inner white area
drawRect(pixels, 28, 22, 26, 38, 255, 248, 240)
// Lines on document
drawRect(pixels, 31, 30, 20, 2, r, g, b)
drawRect(pixels, 31, 38, 16, 2, r, g, b)
drawRect(pixels, 31, 46, 20, 2, r, g, b)
drawRect(pixels, 31, 54, 12, 2, r, g, b)
// Corner fold
drawLine(pixels, 52, 14, 60, 22, r, g, b, 1)
}
function drawUser(pixels, color) {
const [r, g, b] = color
// Head (circle)
drawCircle(pixels, 40, 28, 12, r, g, b)
// Body (half circle / arc)
for (let x = -24; x <= 24; x++) {
const y = Math.round(Math.sqrt(Math.max(0, 24 * 24 - x * x)))
for (let dy = 0; dy < 3; dy++) {
setPixel(pixels, 40 + x, 58 - y + dy, r, g, b)
}
}
// Fill body
for (let y = 48; y <= 66; y++) {
for (let x = 16; x <= 64; x++) {
const dx = x - 40
const dy = y - 58
if (dy * dy + dx * dx <= 26 * 26) {
setPixel(pixels, x, y, r, g, b)
}
}
}
}
// Generate all icons
const icons = [
{ name: 'home', draw: drawHome },
{ name: 'chart', draw: drawChart },
{ name: 'bill', draw: drawBill },
{ name: 'user', draw: drawUser },
]
if (!fs.existsSync(OUT_DIR)) {
fs.mkdirSync(OUT_DIR, { recursive: true })
}
for (const icon of icons) {
// Inactive
const inactivePixels = makePixels(icon.draw, INACTIVE)
const inactivePNG = createPNG(inactivePixels)
fs.writeFileSync(path.join(OUT_DIR, `${icon.name}.png`), inactivePNG)
// Active
const activePixels = makePixels(icon.draw, ACTIVE)
const activePNG = createPNG(activePixels)
fs.writeFileSync(path.join(OUT_DIR, `${icon.name}-active.png`), activePNG)
console.log(`Generated: ${icon.name}.png, ${icon.name}-active.png`)
}
console.log('All icons generated successfully!')