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:
37
client/src/utils/format.ts
Normal file
37
client/src/utils/format.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export function formatAmount(fen: number): string {
|
||||
const yuan = (fen / 100).toFixed(2)
|
||||
return '¥ ' + yuan.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
// Parse YYYY-MM-DD as local time to avoid timezone off-by-one
|
||||
const parts = date.split('-').map(Number)
|
||||
const d = new Date(parts[0], parts[1] - 1, parts[2])
|
||||
const now = new Date()
|
||||
// Reset time portion for accurate day diff
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
const diff = Math.floor((today.getTime() - target.getTime()) / 86400000)
|
||||
|
||||
if (diff === 0) return '今天'
|
||||
if (diff === 1) return '昨天'
|
||||
if (diff === 2) return '前天'
|
||||
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
export function formatMonth(date: string): string {
|
||||
const [year, month] = date.split('-')
|
||||
return `${year}年${parseInt(month)}月`
|
||||
}
|
||||
|
||||
export function getCurrentMonth(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatAmountRaw(fen: number): string {
|
||||
const yuan = (fen / 100).toFixed(2)
|
||||
return yuan.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
90
client/src/utils/request.ts
Normal file
90
client/src/utils/request.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// API 基础地址 - 上线后改成真实域名
|
||||
let BASE_URL = 'http://106.14.208.43:3000/api'
|
||||
if (typeof wx !== 'undefined' && wx.getSystemInfoSync) {
|
||||
// 微信小程序环境使用 HTTPS 域名(上线后配置)
|
||||
// BASE_URL = 'https://your-domain.com/api'
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
data?: any
|
||||
}
|
||||
|
||||
let isRedirectingToLogin = false
|
||||
|
||||
export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
uni.request({
|
||||
url: BASE_URL + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
success: (res: any) => {
|
||||
const { statusCode, data } = res
|
||||
|
||||
// HTTP 401 - token invalid or expired
|
||||
if (statusCode === 401) {
|
||||
uni.removeStorageSync('xc:token')
|
||||
uni.removeStorageSync('xc:userId')
|
||||
if (!isRedirectingToLogin) {
|
||||
isRedirectingToLogin = true
|
||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
// #ifdef H5
|
||||
window.location.reload()
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
// 重新触发 wx.login
|
||||
reLogin()
|
||||
// #endif
|
||||
isRedirectingToLogin = false
|
||||
}, 1500)
|
||||
}
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
return
|
||||
}
|
||||
|
||||
// Business logic success
|
||||
if (data.code === 0) {
|
||||
resolve(data.data)
|
||||
} else {
|
||||
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
||||
reject(data)
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 微信小程序自动重新登录
|
||||
function reLogin() {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
if (loginRes.code) {
|
||||
try {
|
||||
const data = await request<{ token: string; userId: number }>({
|
||||
url: '/auth/login',
|
||||
method: 'POST',
|
||||
data: { code: loginRes.code }
|
||||
})
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
} catch (e) {
|
||||
console.error('[Auth] Re-login failed:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
10
client/src/utils/system.ts
Normal file
10
client/src/utils/system.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
let statusBarHeight = 0
|
||||
|
||||
try {
|
||||
const info = uni.getSystemInfoSync()
|
||||
statusBarHeight = info.statusBarHeight || 0
|
||||
} catch {
|
||||
statusBarHeight = 20
|
||||
}
|
||||
|
||||
export { statusBarHeight }
|
||||
Reference in New Issue
Block a user