feat: 交互优化与代码质量改进

- SvgIcon 改为 PNG 图标方案,组件重命名为 Icon
- 修复 TransactionItem tap 事件名冲突,改为 item-tap
- 修复 Stats 页面切换 tab 时 overview 不更新
- 修复 CategoryIcon bgColor 必填问题
- 修复 ChartWrapper import 顺序
- 修复 budget onDelete 空字符串问题
- 清理 profile 页面未使用 CSS
- 统一所有页面 header 为 sticky 定位
- 修复导航栏返回按钮不统一
- 添加导出功能 loading 状态
- 优化 scroll-into-view 延迟
This commit is contained in:
2026-06-02 16:04:17 +08:00
parent 36baa9a2b5
commit d5e5655b88
34 changed files with 318 additions and 451 deletions

View File

@@ -1,238 +0,0 @@
/**
* 生成 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!')