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

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules/
# Build output
dist/
server/dist/
# Environment files
.env
.env.local
.env.*.local
**/.env
# Logs
*.log
npm-debug.log*
# OS files
.DS_Store
Thumbs.db
desktop.ini
# IDE
.vscode/
.idea/
*.swp
*.swo
# Uni-app
client/dist/
client/unpackage/
# Deploy (keep scripts, ignore generated certs)
*.pem
*.key
# Playwright
.playwright-mcp/

150
CLAUDE.md Normal file
View File

@@ -0,0 +1,150 @@
# 小菜记账 (Xiaocai Accounting)
可爱 × 高级的个人记账小程序。让记账从负担变成享受。
## Tech Stack
### 前端 (client/)
- **框架**: Uni-app (Vue 3 + Composition API)
- **状态管理**: Pinia
- **样式**: SCSS (Claymorphism 风格)
- **图标**: Lucide Icons (SVG 组件,禁止 emoji)
- **图表**: uCharts (小程序端)
- **目标平台**: 微信小程序 / H5
### 后端 (server/)
- **运行时**: Node.js + Express
- **数据库**: MySQL 8.0 (mysql2 连接池)
- **认证**: HMAC-SHA256 签名 token30 天过期
- **CORS**: 已配置,支持 credentials
## Design System
完整设计规格: `docs/superpowers/specs/2026-05-27-xiaocai-design.md`
### 核心设计语言: Claymorphism (软3D粘土风格)
- **主色调**: 珊瑚橙 `#FF8C69` — 温暖、活泼、有亲和力
- **背景色**: 暖奶油 `#FFF8F0` — 比纯白更柔和
- **文字色**: 暖深棕 `#2D1B1B` — 不用纯黑
- **圆角**: 卡片 20px, 按钮 14-16px
- **阴影**: 双层 (外阴影 + 内阴影,模拟粘土质感)
### 字体
- 英文: Fredoka (标题) + Nunito (正文)
- 中文: PingFang SC / 系统默认
- 数字和金额使用 Fredoka等宽数字
### 关键原则
- 最小触摸目标 44×44px
- 禁止使用 emoji 作为图标,统一使用 SvgIcon 组件
- 尊重 prefers-reduced-motion
- 文字对比度 ≥ 4.5:1 (WCAG AA)
## Project Conventions
### 命名
- 目录: kebab-case (`category-manage/`, `transaction-item/`)
- 组件: PascalCase (`AmountCard.vue`, `TransactionItem.vue`)
- 变量/函数: camelCase (`totalExpense`, `formatAmount()`)
- 常量: UPPER_SNAKE_CASE (`MAX_BUDGET`, `STORAGE_KEY`)
- CSS 类: BEM 或 Tailwind 风格
### 文件组织
```
client/src/
├── pages/ # 页面 (uni-app 规范)
│ ├── index/ # 首页仪表盘
│ ├── add/ # 添加/编辑记录
│ ├── stats/ # 统计分析
│ ├── bills/ # 全部账单 (下拉刷新/上拉加载)
│ ├── profile/ # 个人设置
│ └── category-manage/ # 分类管理
├── components/ # 公共组件
│ ├── BudgetBar/ # 预算进度条
│ ├── CategoryIcon/ # 分类图标 (首字+颜色)
│ ├── Numpad/ # 数字键盘
│ ├── SvgIcon/ # SVG 图标 (base64 data URI)
│ └── TransactionItem/ # 交易列表项
├── stores/ # Pinia stores
│ ├── transaction.ts # 交易 CRUD
│ ├── category.ts # 分类 CRUD
│ ├── budget.ts # 预算管理
│ └── stats.ts # 统计概览
├── utils/
│ ├── format.ts # 金额/日期格式化
│ ├── request.ts # HTTP 请求封装 (401 处理/自动重登录)
│ └── system.ts # 系统信息 (状态栏高度等)
└── static/icons/ # tabBar 图标 (PNG)
server/src/
├── index.ts # Express 入口 (CORS, JSON 解析)
├── middleware/auth.ts # HMAC token 验证中间件
├── routes/
│ ├── auth.ts # 登录 / token 签发
│ ├── transaction.ts # 交易 CRUD (用户隔离)
│ ├── category.ts # 分类 CRUD (默认+自定义)
│ ├── budget.ts # 预算 CRUD (按月)
│ └── stats.ts # 统计查询
└── db/
├── connection.ts # MySQL 连接池
├── init.ts # 建表 + seed (multipleStatements)
├── schema.sql # 表结构
└── seed.sql # 默认分类数据
```
### 金额处理
- 存储单位: 分 (整数,避免浮点精度问题)
- 显示单位: 元 (保留两位小数)
- 格式化: `¥ 1,234.56` (¥ 后空格,千分位分隔)
- 收入前缀 `+`,支出前缀 `-`
- 使用 `formatAmount()` 带 ¥ 前缀,`formatAmountRaw()` 纯数字
### 数据持久化
- 前端本地存储 Key: `xc:token`, `xc:userId`
- 后端 MySQL 数据库
- API 基础地址: `request.ts` 中的 `BASE_URL`
### 认证流程
- H5: 页面加载时自动 login 获取 token
- 微信小程序: `App.vue` onLaunch 调用 `wx.login` 获取 code换 token
- Token: HMAC-SHA256 签名30 天过期
- 401 处理: 自动清除 token小程序触发 reLoginH5 刷新页面
### Git
- 分支: `master``main` (注意远程)
- Commit 风格: 中文描述,简洁明了
- `feat: 添加记账页面`
- `fix: 修复金额计算精度问题`
## Commands
```bash
# 前端开发 (H5)
cd client && npm run dev:h5
# 前端开发 (微信小程序)
cd client && npm run dev:mp-weixin
# 前端构建
cd client && npm run build:mp-weixin
# 后端开发
cd server && npm run dev
# 后端构建
cd server && npm run build
# 数据库初始化
cd server && npm run db:init
```
## 部署
- 服务器: `106.14.208.43:3000`
- MySQL: 本地 3306 端口
- 微信小程序上线需配置 HTTPS 域名 (替换 `request.ts` 中的 `BASE_URL`)
## 开发
@DEV.md

42
DEV.md Normal file
View File

@@ -0,0 +1,42 @@
## 通用原则
- 优先保持现有代码风格与架构模式,不要无理由重构。
- 任何功能或修复变更,需确保现有测试通过,必要时补充新测试。
- 所有输出(代码、注释、文档、提交信息)默认使用**简体中文**,专有名词或技术术语可保留英文。
## 编码规范
### 文件与编码
- 所有文件必须保持 UTF-8编码。
- 修改包含中文的文件时,不得造成中文注释、中文文案、错误提示乱码。
- 如果发现文件已有乱码,不要擅自猜测业务含义,应标记出来让人工确认。
## 测试要求
- 新增功能必须编写对应的单元测试或集成测试。
- 修复 Bug 时,先添加能复现问题的测试用例,再修复。
- 测试覆盖率不应低于当前项目基线(若未设置,尽量保持 ≥80%)。
- 测试命名清晰,能描述场景,如 should return error when user not found。
## 注释与文档
### 注释要求
- **保留**已有的重要注释如文件头说明、JSDoc、业务解释
- 新增复杂逻辑、公共 API、关键算法时必须补充**简洁的中文注释**,解释“为什么这么做”而非“做了什么”。
- 不要为了注释而注释,避免冗余的废话(例如 `i++; // 自增 i`)。
- 多行 JSDoc/文档注释保持可读性,**不要压缩成单行**。
## Never 规则
- Never 修改 dist/ 目录
- Never 使用内联样式,除非需要动态计算
- Never 在渲染路径中执行耗时操作
- Never 在列表渲染中省略 key 属性
- Never 修改 lock 文件
- Never 使用 git stash
- Never 切换分支
- Never 过度封装,保持代码简洁明了
## Requirements
- 代码提交前检查代码,包括但不限于代码格式、注释、测试覆盖率等。

14
client/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>小菜记账</title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

15354
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
client/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "xiaocai-client",
"version": "1.0.0",
"private": true,
"scripts": {
"dev:h5": "uni -p h5",
"dev:mp-weixin": "uni -p mp-weixin",
"build:h5": "uni build -p h5",
"build:mp-weixin": "uni build -p mp-weixin",
"lint": "eslint src --ext .ts,.vue"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-alpha-5010120260525001",
"@dcloudio/uni-app-plus": "3.0.0-alpha-5010120260525001",
"@dcloudio/uni-components": "3.0.0-alpha-5010120260525001",
"@dcloudio/uni-h5": "3.0.0-alpha-5010120260525001",
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-5010120260525001",
"pinia": "^2.1.7",
"vue": "^3.4.0"
},
"devDependencies": {
"@dcloudio/types": "^3.4.0",
"@dcloudio/uni-automator": "3.0.0-alpha-5010120260525001",
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260525001",
"@dcloudio/uni-stacktracey": "3.0.0-alpha-5010120260525001",
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260525001",
"@types/node": "^20.11.0",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"eslint": "^8.56.0",
"sass": "^1.70.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
}
}

39
client/src/App.vue Normal file
View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onLaunch } from '@dcloudio/uni-app'
import { request } from '@/utils/request'
onLaunch(() => {
// #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)
console.log('[Auth] Login success')
} catch (e) {
console.error('[Auth] Login failed:', e)
}
}
}
})
// #endif
})
</script>
<style lang="scss">
@import './uni.scss';
page {
background-color: #FFF8F0;
font-family: 'Nunito', 'PingFang SC', sans-serif;
color: #2D1B1B;
-webkit-font-smoothing: antialiased;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<view class="budget-bar-wrap">
<view class="bar-track">
<view class="bar-fill" :style="{ width: percentage + '%', background: fillColor }"></view>
</view>
<view class="bar-info" v-if="showLabel">
<text class="bar-text" v-if="rawPercentage <= 100">预算 {{ formatAmount(total) }} · 剩余 {{ formatAmount(Math.max(0, total - used)) }}</text>
<text class="bar-text over" v-else>已超支 {{ formatAmount(used - total) }}</text>
<text class="bar-pct" :class="{ over: rawPercentage > 100 }">{{ rawPercentage }}%</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { formatAmount } from '@/utils/format'
const props = withDefaults(defineProps<{
total: number
used: number
showLabel?: boolean
}>(), { showLabel: true })
const rawPercentage = computed(() => {
if (props.total <= 0) return 0
return Math.round((props.used / props.total) * 100)
})
const percentage = computed(() => Math.min(100, rawPercentage.value))
const fillColor = computed(() => {
if (rawPercentage.value > 100) return '#FF6B6B'
return 'linear-gradient(90deg, #FF8C69, #FFB89A)'
})
</script>
<style lang="scss" scoped>
.bar-track {
height: 16rpx;
border-radius: 8rpx;
background: #F0E0D6;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 8rpx;
transition: width 0.6s ease-out;
}
.bar-info {
display: flex;
justify-content: space-between;
margin-top: 16rpx;
}
.bar-text {
font-size: 24rpx;
color: #8B7E7E;
}
.bar-pct {
font-family: 'Fredoka', sans-serif;
font-size: 28rpx;
font-weight: 500;
color: #FF8C69;
&.over { color: #FF6B6B; }
}
.bar-text.over {
color: #FF6B6B;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<view class="cat-icon" :class="[size, { selected }]" :style="iconStyle">
<text class="icon-text" :style="{ color: color, fontSize: fontSize }">{{ label }}</text>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label: string
color: string
bgColor: string
size?: 'sm' | 'md' | 'lg'
selected?: boolean
}>()
const sizeMap = { sm: 64, md: 88, lg: 112 }
const fontMap = { sm: 28, md: 36, lg: 48 }
const iconStyle = computed(() => ({
background: props.bgColor || (props.color + '15'),
width: (sizeMap[props.size || 'md']) + 'rpx',
height: (sizeMap[props.size || 'md']) + 'rpx',
}))
const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
</script>
<style lang="scss" scoped>
.cat-icon {
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid transparent;
transition: all 0.2s;
}
.icon-text {
font-weight: 600;
}
.selected {
border-color: #FF8C69;
background: #FFE8E0 !important;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<view class="numpad">
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
<text class="key-text">{{ key }}</text>
</view>
<view class="key fn" @tap="emit('delete')">
<SvgIcon name="trash" :size="36" color="#FF8C69" />
</view>
<view class="key eq" @tap="emit('confirm')">
<SvgIcon name="check" :size="36" color="#FFFFFF" />
</view>
</view>
</template>
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
const emit = defineEmits(['input', 'delete', 'confirm'])
function onKeyTap(key: string) {
emit('input', key)
}
</script>
<style lang="scss" scoped>
.numpad {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
padding: 0 32rpx;
}
.key {
height: 96rpx;
border-radius: 24rpx;
background: #FFF8F0;
display: flex;
align-items: center;
justify-content: center;
&:active {
background: #F0E0D6;
}
}
.key-text {
font-family: 'Fredoka', sans-serif;
font-size: 44rpx;
font-weight: 500;
color: #2D1B1B;
}
.key.fn {
background: #FFE8E0;
&:active {
background: #FFD0C0;
}
}
.key.eq {
background: linear-gradient(135deg, #FF8C69, #E67355);
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
&:active {
background: #E67355;
}
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<view class="skeleton" :style="style" :class="[variant]"></view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
width?: string
height?: string
variant?: 'text' | 'rect' | 'circle'
}>(), { variant: 'rect' })
const style = computed(() => ({
width: props.width || (props.variant === 'circle' ? props.height : '100%'),
height: props.height || '32rpx',
}))
</script>
<style lang="scss" scoped>
.skeleton {
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8rpx;
&.text {
border-radius: 4rpx;
}
&.circle {
border-radius: 50%;
}
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<!-- #ifdef H5 -->
<image class="svg-icon" :src="svgData" :style="sizeStyle" mode="aspectFit" />
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view class="svg-icon-wrap" :style="sizeStyle">
<rich-text :nodes="svgNode" :style="sizeStyle"></rich-text>
</view>
<!-- #endif -->
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
name: string
size?: number
color?: string
}>(), { size: 40, color: '#2D1B1B' })
const icons: Record<string, string> = {
close: '<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
bell: '<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'trend-down': '<path d="M23 18l-9.5-9.5-5 5L1 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 18h6v-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'trend-up': '<path d="M23 6l-9.5 9.5-5-5L1 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 6h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
edit: '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
budget: '<path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
category: '<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/>',
export: '<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
moon: '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
'alert-circle': '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
info: '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 16v-4M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
chevronRight: '<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
chevronLeft: '<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
arrowLeft: '<path d="M19 12H5M12 19l-7-7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
arrowRight: '<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
trash: '<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
check: '<path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
}
const svgData = computed(() => {
const path = icons[props.name] || ''
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${props.color}" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
return 'data:image/svg+xml;base64,' + btoa(svg)
})
const svgNode = computed(() => {
const path = icons[props.name] || ''
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${props.color}" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
})
const sizeStyle = computed(() => {
const s = props.size + 'rpx'
return { width: s, height: s }
})
</script>
<style scoped>
.svg-icon {
display: inline-block;
}
.svg-icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<view class="tx-item" @tap="$emit('tap', item)" @longpress="$emit('longpress', item)">
<view class="icon-wrap" :style="{ background: item.category_color + '20' }">
<text class="icon-text" :style="{ color: item.category_color }">{{ item.category_name?.[0] || '?' }}</text>
</view>
<view class="info">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
<text class="meta">{{ item.category_name || '未分类' }}{{ hideDate ? '' : ' · ' + formatDate(item.date) }}</text>
</view>
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
</view>
</template>
<script setup lang="ts">
import { formatAmount, formatDate } from '@/utils/format'
withDefaults(defineProps<{
item: {
id: number
amount: number
type: 'expense' | 'income'
note?: string
date: string
category_name?: string
category_icon?: string
category_color?: string
}
hideDate?: boolean
}>(), { hideDate: false })
defineEmits(['tap', 'longpress'])
</script>
<style lang="scss" scoped>
.tx-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
border-radius: 20rpx;
transition: background 0.15s;
&:active {
background: #FFF0E6;
}
}
.icon-wrap {
width: 80rpx;
height: 80rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.icon-text {
font-size: 36rpx;
font-weight: 600;
}
.info {
flex: 1;
min-width: 0;
}
.name {
font-size: 28rpx;
font-weight: 500;
color: #2D1B1B;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: 24rpx;
color: #8B7E7E;
display: block;
margin-top: 4rpx;
}
.amount {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
&.expense { color: #2D1B1B; }
&.income { color: #7BC67E; }
}
</style>

14
client/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="@dcloudio/types" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
// WeChat Mini Program global
declare const wx: UniApp.Wx & {
env: {
USER_DATA_PATH: string
}
}

10
client/src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
return { app }
}

27
client/src/manifest.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "小菜记账",
"appid": "wx854804dff2928c53",
"description": "可爱高级的个人记账小程序",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,
"mp-weixin": {
"appid": "wx854804dff2928c53",
"setting": {
"urlCheck": true,
"es6": true,
"postcss": true,
"minified": true
},
"usingComponents": true,
"optimization": {
"subPackages": true
}
},
"h5": {
"title": "小菜记账",
"router": {
"mode": "hash"
}
}
}

95
client/src/pages.json Normal file
View File

@@ -0,0 +1,95 @@
{
"pages": [
{
"path": "pages/index/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "小菜记账",
"enablePullDownRefresh": true
}
},
{
"path": "pages/add/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "记一笔"
}
},
{
"path": "pages/stats/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "统计分析",
"enablePullDownRefresh": true
}
},
{
"path": "pages/bills/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "全部账单",
"enablePullDownRefresh": true
}
},
{
"path": "pages/profile/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/category-manage/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "分类管理"
}
},
{
"path": "pages/budget/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "预算设置"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "小菜记账",
"navigationBarBackgroundColor": "#FFF8F0",
"backgroundColor": "#FFF8F0",
"backgroundTextStyle": "dark"
},
"tabBar": {
"color": "#BFB3B3",
"selectedColor": "#FF8C69",
"backgroundColor": "#FFF8F0",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/icons/home.png",
"selectedIconPath": "static/icons/home-active.png"
},
{
"pagePath": "pages/stats/index",
"text": "统计",
"iconPath": "static/icons/chart.png",
"selectedIconPath": "static/icons/chart-active.png"
},
{
"pagePath": "pages/bills/index",
"text": "账单",
"iconPath": "static/icons/bill.png",
"selectedIconPath": "static/icons/bill-active.png"
},
{
"pagePath": "pages/profile/index",
"text": "我的",
"iconPath": "static/icons/user.png",
"selectedIconPath": "static/icons/user-active.png"
}
]
}
}

View File

@@ -0,0 +1,361 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="close" @tap="goBack">
<SvgIcon name="close" :size="36" color="#8B7E7E" />
</view>
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
<view style="width: 64rpx;"></view>
</view>
<view class="type-toggle-wrap">
<view class="type-toggle" :class="{ income: type === 'income' }">
<view class="slider"></view>
<view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view>
<view class="tab" :class="{ active: type === 'income' }" @tap="switchType('income')">收入</view>
</view>
</view>
<view class="amount-display">
<text class="currency">¥</text>
<text class="digits">{{ displayAmount }}</text>
</view>
<scroll-view class="category-scroll" scroll-y>
<view class="category-grid">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-item" @tap="selectedCat = cat.id">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
</view>
</view>
</scroll-view>
<view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<view class="extra-row">
<SvgIcon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text>
</view>
</picker>
<view class="extra-row">
<SvgIcon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="50" />
</view>
</view>
<Numpad @input="onInput" @delete="onDelete" @confirm="save" />
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction'
import Numpad from '@/components/Numpad/Numpad.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import { statusBarHeight } from '@/utils/system'
const catStore = useCategoryStore()
const txStore = useTransactionStore()
const type = ref<'expense' | 'income'>('expense')
const amountStr = ref('0')
const selectedCat = ref<number | null>(null)
const note = ref('')
const selectedDate = ref(new Date().toISOString().slice(0, 10))
const saving = ref(false)
const editId = ref<number | null>(null)
const todayStr = new Date().toISOString().slice(0, 10)
// Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
const dateLabel = computed(() => {
const d = new Date(selectedDate.value)
return `${d.getMonth() + 1}${d.getDate()}`
})
const displayAmount = computed(() => {
const num = parseFloat(amountStr.value) || 0
return num.toFixed(2)
})
const currentCategories = computed(() => catStore.getByType(type.value))
function switchType(newType: 'expense' | 'income') {
// Remember current selection before switching
lastCatByType[type.value] = selectedCat.value
type.value = newType
const cats = catStore.getByType(newType)
// Restore last selection for this type, or fall back to first category
const saved = lastCatByType[newType]
if (saved && cats.find(c => c.id === saved)) {
selectedCat.value = saved
} else {
selectedCat.value = cats.length > 0 ? cats[0].id : null
}
}
watch(currentCategories, (cats) => {
if (cats.length > 0 && !cats.find(c => c.id === selectedCat.value)) {
selectedCat.value = cats[0].id
}
})
onMounted(async () => {
await catStore.fetchCategories()
const pages = getCurrentPages()
const page = pages[pages.length - 1] as any
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
type.value = page.options.type
}
if (page?.options?.editId) {
editId.value = Number(page.options.editId)
// Try to find in store first, otherwise fetch from server
let existing = txStore.transactions.find(t => t.id === editId.value)
if (!existing) {
existing = await txStore.fetchTransactionById(editId.value!)
}
if (existing) {
type.value = existing.type
amountStr.value = (existing.amount / 100).toFixed(2)
selectedCat.value = existing.category_id
lastCatByType[existing.type] = existing.category_id
note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10)
} else {
uni.showToast({ title: '记录不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
}
if (currentCategories.value.length > 0 && !selectedCat.value) {
selectedCat.value = currentCategories.value[0].id
}
})
function onDateChange(e: any) {
selectedDate.value = e.detail.value
}
function onInput(key: string) {
if (key === '.' && amountStr.value.includes('.')) return
// When current value is '0', replace with new key (but '0' and '00' should stay as '0')
let next: string
if (amountStr.value === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = amountStr.value + key
}
const num = parseFloat(next)
if (!isNaN(num) && num > 9999999.99) return
// Check decimal limit on the resulting value (handles "00" adding 2 chars)
if (next.includes('.')) {
const decimals = next.split('.')[1]
if (decimals && decimals.length > 2) return
}
amountStr.value = next
}
function onDelete() {
if (amountStr.value.length > 1) {
amountStr.value = amountStr.value.slice(0, -1)
} else {
amountStr.value = '0'
}
}
async function save() {
if (saving.value) return
const amount = Math.round(parseFloat(amountStr.value) * 100)
if (amount <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' })
return
}
if (!selectedCat.value) {
uni.showToast({ title: '请选择分类', icon: 'none' })
return
}
saving.value = true
try {
const data = {
amount,
type: type.value,
category_id: selectedCat.value,
note: note.value,
date: selectedDate.value
}
if (editId.value) {
await txStore.updateTransaction(editId.value, data)
uni.showToast({ title: '修改成功', icon: 'success' })
} else {
await txStore.addTransaction(data)
uni.showToast({ title: '记账成功', icon: 'success' })
}
setTimeout(() => uni.navigateBack(), 1500)
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
function goBack() {
const hasInput = amountStr.value !== '0' || note.value
if (hasInput) {
uni.showModal({
title: '提示',
content: '放弃当前记录?',
success: (res) => {
if (res.confirm) uni.navigateBack()
}
})
} else {
uni.navigateBack()
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
display: flex;
flex-direction: column;
}
.status-bar {
background: #FFF8F0;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 40rpx;
}
.close {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.title { font-size: 32rpx; font-weight: 500; color: #2D1B1B; }
.type-toggle-wrap { display: flex; justify-content: center; margin: 16rpx 0; }
.type-toggle {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
position: relative;
}
.tab {
width: 160rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #8B7E7E;
border-radius: 20rpx;
position: relative;
z-index: 1;
transition: color 0.2s;
&.active { color: #FF8C69; font-weight: 600; }
}
.slider {
position: absolute;
top: 8rpx;
left: 8rpx;
width: 160rpx;
height: 72rpx;
background: #FFFFFF;
border-radius: 20rpx;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.08);
transition: transform 0.2s;
}
.income .slider { transform: translateX(160rpx); }
.amount-display {
text-align: center;
padding: 32rpx 40rpx 24rpx;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: 48rpx;
font-weight: 500;
color: #BFB3B3;
margin-right: 8rpx;
}
.digits {
font-family: 'Fredoka', sans-serif;
font-size: 88rpx;
font-weight: 700;
color: #2D1B1B;
font-variant-numeric: tabular-nums;
}
.category-scroll {
max-height: 320rpx;
padding: 0 40rpx;
}
.category-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
padding: 8rpx 0;
}
.cat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
padding: 12rpx 0;
&:active { transform: scale(0.95); }
}
.cat-name { font-size: 22rpx; color: #8B7E7E; }
.cat-name.active {
color: #FF8C69;
font-weight: 600;
}
.extra-fields {
padding: 16rpx 40rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.extra-row {
height: 88rpx;
background: #FFFFFF;
border-radius: 20rpx;
border: 2rpx solid #F0E0D6;
padding: 0 24rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.extra-text { font-size: 28rpx; color: #2D1B1B; flex: 1; }
.extra-input { font-size: 28rpx; color: #2D1B1B; flex: 1; }
.placeholder { color: #BFB3B3; }
</style>

View File

@@ -0,0 +1,285 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">全部账单</text>
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: filterType === 'all' }" @tap="switchFilter('all')">全部</view>
<view class="tab" :class="{ active: filterType === 'expense' }" @tap="switchFilter('expense')">支出</view>
<view class="tab" :class="{ active: filterType === 'income' }" @tap="switchFilter('income')">收入</view>
</view>
</view>
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
<view v-for="group in groupedTx" :key="group.date" class="date-group">
<view class="group-header">
<text class="group-date">{{ group.label }}</text>
<text class="group-total" :class="{ expense: group.expense > 0 }">
{{ group.expense > 0 ? '- ' + formatAmount(group.expense) : '' }}
{{ group.income > 0 ? '+ ' + formatAmount(group.income) : '' }}
</text>
</view>
<TransactionItem
v-for="item in group.items"
:key="item.id"
:item="item"
:hideDate="true"
@tap="onEdit(item)"
@longpress="onDelete(item)"
/>
</view>
</view>
<view class="tx-list" v-if="txStore.loading && txList.length === 0">
<view v-for="g in 2" :key="g" class="date-group">
<view class="group-header">
<Skeleton width="120rpx" height="26rpx" variant="text" />
<Skeleton width="160rpx" height="24rpx" variant="text" />
</view>
<view v-for="i in 3" :key="i" class="skeleton-item">
<Skeleton width="80rpx" height="80rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="160rpx" height="28rpx" variant="text" />
<Skeleton width="240rpx" height="24rpx" variant="text" />
</view>
<Skeleton width="120rpx" height="32rpx" variant="rect" />
</view>
</view>
</view>
<view class="loading-more" v-if="txStore.loading && txList.length > 0">
<text class="loading-text">加载中...</text>
</view>
<view class="loading-more" v-if="noMore && txList.length > 0">
<text class="loading-text">没有更多了</text>
</view>
<view class="state-box" v-if="loadError">
<SvgIcon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="empty" v-if="txList.length === 0 && !txStore.loading && !loadError">
<text class="empty-text">暂无账单记录</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
const txStore = useTransactionStore()
const filterType = ref('all')
const page = ref(1)
const pageSize = 20
const txList = ref<any[]>([])
const total = ref(0)
const loadError = ref(false)
const noMore = computed(() => txList.value.length >= total.value && total.value > 0)
const groupedTx = computed(() => {
const groups: Record<string, { date: string; label: string; expense: number; income: number; items: any[] }> = {}
for (const tx of txList.value) {
if (!groups[tx.date]) {
groups[tx.date] = { date: tx.date, label: formatDate(tx.date), expense: 0, income: 0, items: [] }
}
groups[tx.date].items.push(tx)
if (tx.type === 'expense') {
groups[tx.date].expense += tx.amount
} else {
groups[tx.date].income += tx.amount
}
}
return Object.values(groups).sort((a, b) => b.date.localeCompare(a.date))
})
onMounted(() => loadTx(true))
function switchFilter(type: string) {
filterType.value = type
loadTx(true)
}
async function loadTx(reset = false) {
if (reset) {
page.value = 1
txList.value = []
}
const params: any = { page: page.value, pageSize }
if (filterType.value !== 'all') params.type = filterType.value
try {
loadError.value = false
await txStore.fetchTransactions(params)
if (reset) {
txList.value = txStore.transactions
} else {
txList.value = [...txList.value, ...txStore.transactions]
}
total.value = txStore.total
} catch (e) {
console.error('Load bills error:', e)
loadError.value = true
}
}
function onEdit(item: any) {
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
async function onDelete(item: any) {
uni.showModal({
title: '删除记录',
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}`,
success: async (res) => {
if (res.confirm) {
try {
await txStore.deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// 触底加载更多
onReachBottom(() => {
if (noMore.value || txStore.loading) return
page.value++
loadTx(false)
})
// 下拉刷新
onPullDownRefresh(() => {
loadTx(true).finally(() => uni.stopPullDownRefresh())
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.status-bar {
background: #FFF8F0;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
}
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 32rpx; }
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
&.active {
background: #FFFFFF;
color: #FF8C69;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
}
}
.tx-list { padding: 0 40rpx; }
.date-group {
margin-bottom: 24rpx;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #F0E0D6;
margin-bottom: 8rpx;
}
.group-date {
font-size: 26rpx;
font-weight: 600;
color: #2D1B1B;
}
.group-total {
font-family: 'Fredoka', sans-serif;
font-size: 24rpx;
color: #8B7E7E;
&.expense { color: #2D1B1B; }
}
.skeleton-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.loading-more {
text-align: center;
padding: 32rpx 0;
}
.loading-text { font-size: 24rpx; color: #BFB3B3; }
.empty {
text-align: center;
padding: 120rpx 0;
}
.empty-text { font-size: 28rpx; color: #BFB3B3; }
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 120rpx 0;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
}
</style>

View File

@@ -0,0 +1,335 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<SvgIcon name="chevronLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">预算设置</text>
<view class="nav-placeholder"></view>
</view>
<view class="content">
<!-- 当前预算预览 -->
<view class="preview-card">
<view class="preview-header">
<text class="preview-label">本月预算</text>
<text class="preview-month">{{ currentMonth }}</text>
</view>
<text class="preview-amount" v-if="!loading">
{{ budget ? formatAmount(budget.amount) : '未设置' }}
</text>
<view class="preview-skeleton" v-else></view>
<BudgetBar
v-if="budget"
:total="budget.amount"
:used="monthExpense"
:showLabel="true"
/>
<text class="preview-hint" v-else>设置预算掌控开支</text>
</view>
<!-- 快捷金额 -->
<view class="section-title">快捷金额</view>
<view class="preset-grid">
<view
class="preset-item"
v-for="p in presets"
:key="p"
:class="{ active: amountStr === String(p) }"
@tap="selectPreset(p)"
>
<text class="preset-text">{{ formatPreset(p) }}</text>
</view>
</view>
<!-- 自定义金额 -->
<view class="section-title">自定义金额</view>
<view class="custom-input">
<text class="input-prefix">¥</text>
<text class="input-value" :class="{ placeholder: !amountStr }">
{{ amountStr || '0.00' }}
</text>
<text class="input-unit"></text>
</view>
<!-- Numpad -->
<Numpad
@input="onInput"
@delete="onDelete"
@confirm="onConfirm"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useBudgetStore } from '@/stores/budget'
import { useStatsStore } from '@/stores/stats'
import { formatAmount, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import Numpad from '@/components/Numpad/Numpad.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
const budgetStore = useBudgetStore()
const statsStore = useStatsStore()
const currentMonth = getCurrentMonth()
const budget = ref<any>(null)
const monthExpense = ref(0)
const loading = ref(true)
const amountStr = ref('')
const presets = [1000, 2000, 3000, 5000, 10000]
function formatPreset(val: number) {
return val >= 10000 ? (val / 10000) + '万' : val.toLocaleString()
}
onMounted(async () => {
loading.value = true
try {
await Promise.all([
budgetStore.fetchBudget(currentMonth),
statsStore.fetchOverview(currentMonth)
])
budget.value = budgetStore.budget
monthExpense.value = statsStore.overview.expense
if (budget.value) {
amountStr.value = (budget.value.amount / 100).toString()
}
} catch (e) {
console.error('Budget page load error:', e)
} finally {
loading.value = false
}
})
function selectPreset(val: number) {
amountStr.value = String(val)
}
function onInput(key: string) {
if (key === '.' && amountStr.value.includes('.')) return
if (key === '.' && !amountStr.value) {
amountStr.value = '0.'
return
}
let next: string
if (amountStr.value === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = amountStr.value + key
}
const parts = next.split('.')
if (parts[1] && parts[1].length > 2) return
if (next.length > 10) return
amountStr.value = next
}
function onDelete() {
amountStr.value = amountStr.value.slice(0, -1)
}
async function onConfirm() {
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入有效金额', icon: 'none' })
return
}
if (amount > 999999999) {
uni.showToast({ title: '金额过大', icon: 'none' })
return
}
try {
await budgetStore.setBudget(amount, currentMonth)
budget.value = budgetStore.budget
uni.showToast({ title: '预算已设置', icon: 'success' })
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
}
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding-bottom: 120rpx;
}
.status-bar {
background: #FFF8F0;
}
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 32rpx;
}
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
}
.nav-placeholder {
width: 72rpx;
}
.content {
padding: 0 40rpx;
}
.preview-card {
background: #FFFFFF;
border-radius: 40rpx;
padding: 48rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin-bottom: 48rpx;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.preview-label {
font-size: 28rpx;
color: #8B7E7E;
}
.preview-month {
font-size: 24rpx;
color: #BFB3B3;
}
.preview-amount {
font-family: 'Fredoka', sans-serif;
font-size: 56rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
margin-bottom: 24rpx;
}
.preview-skeleton {
width: 240rpx;
height: 64rpx;
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
background-size: 200% 100%;
border-radius: 12rpx;
margin-bottom: 24rpx;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.preview-hint {
font-size: 28rpx;
color: #BFB3B3;
display: block;
text-align: center;
padding: 16rpx 0;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #2D1B1B;
margin-bottom: 24rpx;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
margin-bottom: 48rpx;
}
.preset-item {
height: 96rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:active {
background: #FFF8F0;
border-color: #FF8C69;
}
&.active {
background: #FFE8E0;
border-color: #FF8C69;
}
}
.preset-text {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-weight: 500;
color: #2D1B1B;
}
.custom-input {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 32rpx;
padding: 24rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
}
.input-prefix {
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
font-weight: 600;
color: #FF8C69;
}
.input-value {
font-family: 'Fredoka', sans-serif;
font-size: 56rpx;
font-weight: 600;
color: #2D1B1B;
flex: 1;
&.placeholder {
color: #D0C4C4;
}
}
.input-unit {
font-size: 28rpx;
color: #8B7E7E;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="back" @tap="uni.navigateBack()">
<SvgIcon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">分类管理</text>
<view style="width: 88rpx;"></view>
</view>
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: tabType === 'expense' }" @tap="tabType = 'expense'">支出</view>
<view class="tab" :class="{ active: tabType === 'income' }" @tap="tabType = 'income'">收入</view>
</view>
</view>
<view class="cat-list">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-row">
<view class="cat-info">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
<text class="cat-name">{{ cat.name }}</text>
<text class="cat-badge" v-if="cat.is_custom">自定义</text>
</view>
<view class="cat-actions" v-if="cat.is_custom" @tap="onDelete(cat)">
<SvgIcon name="trash" :size="28" color="#FF6B6B" />
</view>
</view>
</view>
<view class="add-section">
<text class="add-title">添加自定义分类</text>
<view class="add-form">
<input class="add-input" v-model="newName" placeholder="分类名称" maxlength="10" />
<view class="add-btn" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useCategoryStore } from '@/stores/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import { statusBarHeight } from '@/utils/system'
const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense')
const newName = ref('')
const currentCategories = computed(() => catStore.getByType(tabType.value))
onMounted(() => catStore.fetchCategories())
async function onAdd() {
const name = newName.value.trim()
if (!name) return
const colors = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const color = colors[Math.floor(Math.random() * colors.length)]
try {
await catStore.addCategory({
name,
icon: name[0],
color,
type: tabType.value
})
newName.value = ''
uni.showToast({ title: '添加成功', icon: 'success' })
} catch {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
async function onDelete(cat: any) {
uni.showModal({
title: '删除分类',
content: `确定删除「${cat.name}」?该分类下的记录将变为"未分类"。`,
success: async (res) => {
if (res.confirm) {
try {
await catStore.deleteCategory(cat.id)
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.status-bar { background: #FFF8F0; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 40rpx;
}
.back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.title { font-size: 32rpx; font-weight: 600; color: #2D1B1B; }
.tabs-wrap { display: flex; justify-content: center; margin: 16rpx 0 32rpx; }
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
&.active {
background: #FFFFFF;
color: #FF8C69;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
}
}
.cat-list { padding: 0 40rpx; }
.cat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #F0E0D6;
&:last-child { border-bottom: none; }
}
.cat-info {
display: flex;
align-items: center;
gap: 16rpx;
}
.cat-name { font-size: 28rpx; color: #2D1B1B; font-weight: 500; }
.cat-badge {
font-size: 20rpx;
color: #FF8C69;
background: #FFE8E0;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.cat-actions {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.add-section {
margin: 48rpx 40rpx 0;
padding: 32rpx;
background: #FFFFFF;
border-radius: 32rpx;
border: 2rpx solid #F0E0D6;
}
.add-title {
font-size: 28rpx;
font-weight: 500;
color: #2D1B1B;
display: block;
margin-bottom: 24rpx;
}
.add-form {
display: flex;
gap: 16rpx;
}
.add-input {
flex: 1;
height: 88rpx;
background: #FFF8F0;
border-radius: 20rpx;
padding: 0 24rpx;
font-size: 28rpx;
}
.add-btn {
width: 160rpx;
height: 88rpx;
background: linear-gradient(135deg, #FF8C69, #E67355);
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
color: #FFFFFF;
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style>

View File

@@ -0,0 +1,354 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="avatar-wrap">
<SvgIcon name="user" :size="36" color="#8B7E7E" />
</view>
<text class="greeting">{{ greeting }}小菜</text>
<view class="bell" @tap="uni.showToast({ title: '暂无通知', icon: 'none' })">
<SvgIcon name="bell" :size="40" color="#8B7E7E" />
</view>
</view>
<view class="overview-card">
<text class="card-label">本月支出</text>
<view class="amount-row" v-if="!loading">
<text class="currency">¥</text>
<text class="amount">{{ formatAmountRaw(overview.expense) }}</text>
</view>
<view class="amount-skeleton" v-else>
<Skeleton width="280rpx" height="72rpx" variant="rect" />
</view>
<BudgetBar v-if="!loading" :total="budget?.amount || DEFAULT_BUDGET" :used="overview.expense" />
<Skeleton v-else width="100%" height="16rpx" variant="text" />
<view class="mini-stats">
<view class="mini-stat">
<text class="mini-label">收入</text>
<text class="mini-value income" v-if="!loading">+{{ formatAmount(overview.income) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
<view class="mini-stat">
<text class="mini-label">支出</text>
<text class="mini-value expense" v-if="!loading">-{{ formatAmount(overview.expense) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
</view>
</view>
<view class="quick-actions">
<view class="quick-btn" @tap="goAdd('expense')">
<SvgIcon name="trend-down" :size="48" color="#FF6B6B" />
<text class="quick-text">记支出</text>
</view>
<view class="quick-btn" @tap="goAdd('income')">
<SvgIcon name="trend-up" :size="48" color="#7BC67E" />
<text class="quick-text">记收入</text>
</view>
</view>
<view class="section-header">
<text class="section-title">近期账单</text>
<text class="section-more" @tap="goBills">查看全部</text>
</view>
<view class="tx-list" v-if="!loading && !loadError && recentTx.length > 0">
<TransactionItem v-for="item in recentTx" :key="item.id" :item="item" @tap="onTxTap(item)" />
</view>
<view class="tx-list" v-if="loading">
<view v-for="i in 3" :key="i" class="skeleton-item">
<Skeleton width="80rpx" height="80rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="160rpx" height="28rpx" variant="text" />
<Skeleton width="240rpx" height="24rpx" variant="text" />
</view>
<Skeleton width="120rpx" height="32rpx" variant="rect" />
</view>
</view>
<view class="state-box" v-if="loadError">
<SvgIcon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="state-box" v-if="!loading && !loadError && recentTx.length === 0">
<SvgIcon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget'
import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const DEFAULT_BUDGET = 500000 // 5000元
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const budget = ref<any>(null)
const recentTx = ref<any[]>([])
const loading = ref(true)
const loadError = ref(false)
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
async function loadData() {
try {
loading.value = true
loadError.value = false
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 })
])
overview.value = statsStore.overview
budget.value = budgetStore.budget
recentTx.value = txStore.transactions.slice(0, 10)
} catch (e) {
console.error('Load error:', e)
loadError.value = true
} finally {
loading.value = false
}
}
onMounted(() => loadData())
// Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => {
if (!loading.value) loadData()
})
onPullDownRefresh(() => {
loadData().finally(() => uni.stopPullDownRefresh())
})
function goAdd(type: string) {
uni.navigateTo({ url: `/pages/add/index?type=${type}` })
}
function onTxTap(item: any) {
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
function goBills() {
uni.switchTab({ url: '/pages/bills/index' })
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.status-bar {
background: #FFF8F0;
}
.header {
display: flex;
align-items: center;
padding: 16rpx 40rpx 32rpx;
}
.avatar-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
border: 2rpx solid #F0E0D6;
background: #FFF0E6;
display: flex;
align-items: center;
justify-content: center;
}
.greeting {
flex: 1;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
margin-left: 16rpx;
}
.bell {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.overview-card {
margin: 0 40rpx;
padding: 48rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08),
inset -2rpx -2rpx 6rpx rgba(45, 27, 27, 0.04);
}
.card-label {
font-size: 28rpx;
color: #8B7E7E;
display: block;
margin-bottom: 12rpx;
}
.amount-row {
display: flex;
align-items: baseline;
margin-bottom: 32rpx;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: 44rpx;
font-weight: 500;
color: #8B7E7E;
margin-right: 4rpx;
}
.amount {
font-family: 'Fredoka', sans-serif;
font-size: 72rpx;
font-weight: 600;
color: #2D1B1B;
font-variant-numeric: tabular-nums;
}
.mini-stats {
display: flex;
gap: 24rpx;
margin-top: 40rpx;
}
.mini-stat {
flex: 1;
padding: 24rpx;
border-radius: 24rpx;
background: #FFF8F0;
text-align: center;
}
.mini-label {
font-size: 24rpx;
color: #8B7E7E;
display: block;
margin-bottom: 8rpx;
}
.mini-value {
font-family: 'Fredoka', sans-serif;
font-size: 36rpx;
font-weight: 600;
&.income { color: #7BC67E; }
&.expense { color: #FF6B6B; }
}
.quick-actions {
display: flex;
gap: 24rpx;
padding: 0 40rpx;
margin-top: 40rpx;
}
.quick-btn {
flex: 1;
height: 144rpx;
border-radius: 32rpx;
background: #FFFFFF;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
&:active {
transform: scale(0.97);
box-shadow: inset 2rpx 2rpx 6rpx rgba(45, 27, 27, 0.1);
}
}
.quick-text { font-size: 24rpx; font-weight: 500; color: #2D1B1B; }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 48rpx 40rpx 24rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 500;
color: #8B7E7E;
}
.section-more {
font-size: 24rpx;
color: #FF8C69;
}
.tx-list {
padding: 0 40rpx;
}
.amount-skeleton {
margin-bottom: 32rpx;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 80rpx 0;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
}
</style>

View File

@@ -0,0 +1,387 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">我的</text>
<view class="profile-card">
<view class="p-avatar">
<SvgIcon name="user" :size="64" color="#FFFFFF" />
</view>
<view class="p-info">
<text class="p-name">小菜</text>
<text class="p-tagline">记账小能手</text>
<text class="p-streak">累计 {{ txStore.total }} 笔记录</text>
</view>
</view>
<view class="quick-stats">
<view class="qs-card">
<text class="qs-label">本月支出</text>
<text class="qs-value" v-if="!loading">{{ formatAmount(overview.expense) }}</text>
<view class="qs-skeleton" v-else></view>
</view>
<view class="qs-card">
<text class="qs-label">本月收入</text>
<text class="qs-value income" v-if="!loading">{{ formatAmount(overview.income) }}</text>
<view class="qs-skeleton" v-else></view>
</view>
</view>
<view class="menu-card">
<view class="menu-item" @tap="goBudget">
<text class="mi-label">预算设置</text>
<text class="mi-extra" v-if="budget">{{ formatAmount(budget.amount) }}</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goCategoryManage">
<text class="mi-label">分类管理</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="exportData">
<text class="mi-label">数据导出</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<view class="menu-card mt">
<view class="menu-item" @tap="showAbout = true">
<text class="mi-label">关于小菜</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<!-- 关于弹窗 -->
<view class="modal-mask" v-if="showAbout" @tap="showAbout = false">
<view class="modal" @tap.stop>
<text class="about-title">小菜记账</text>
<text class="about-version">v1.0.0</text>
<text class="about-desc">可爱 x 高级的个人记账小程序</text>
<text class="about-desc">让记账从负担变成享受</text>
<view class="modal-btns" style="margin-top: 40rpx;">
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useTransactionStore } from '@/stores/transaction'
import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget'
import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import { request } from '@/utils/request'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const budget = ref<any>(null)
const loading = ref(true)
const showAbout = ref(false)
onMounted(async () => {
loading.value = true
try {
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 })
])
overview.value = statsStore.overview
budget.value = budgetStore.budget
} catch (e) {
console.error('Profile load error:', e)
} finally {
loading.value = false
}
})
function goBudget() {
uni.navigateTo({ url: '/pages/budget/index' })
}
function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
async function exportData() {
try {
const data = await request<any>({ url: '/transactions', data: { page: 1, pageSize: 9999 } })
const list = data.list
if (!list || list.length === 0) {
uni.showToast({ title: '暂无数据', icon: 'none' })
return
}
if (data.total > list.length) {
uni.showToast({ title: `${data.total}条,仅导出前${list.length}`, icon: 'none' })
}
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
}
return val
}
const header = '日期,类型,分类,金额(元),备注\n'
const rows = list.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
}).join('\n')
const csv = '' + header + rows
// #ifdef H5
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `小菜记账_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${new Date().toISOString().slice(0, 10)}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
uni.openDocument({
filePath,
fileType: 'csv',
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
} catch {
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.status-bar {
background: #FFF8F0;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
}
.profile-card {
margin: 0 40rpx;
padding: 48rpx;
background: linear-gradient(135deg, #FF8C69, #FFB89A);
border-radius: 40rpx;
box-shadow: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.1);
display: flex;
align-items: center;
gap: 32rpx;
}
.p-avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
}
.p-name {
font-size: 40rpx;
font-weight: 600;
color: #FFFFFF;
display: block;
}
.p-tagline {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
display: block;
margin-top: 4rpx;
}
.p-streak {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
display: block;
margin-top: 16rpx;
}
.quick-stats {
display: flex;
gap: 24rpx;
padding: 0 40rpx;
margin-top: 32rpx;
}
.qs-card {
flex: 1;
padding: 32rpx 24rpx;
background: #FFFFFF;
border-radius: 32rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
text-align: center;
}
.qs-label {
font-size: 24rpx;
color: #8B7E7E;
display: block;
margin-bottom: 8rpx;
}
.qs-value {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
&.income { color: #7BC67E; }
}
.qs-skeleton {
width: 160rpx;
height: 40rpx;
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
background-size: 200% 100%;
border-radius: 8rpx;
margin: 0 auto;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.menu-card {
margin: 40rpx 40rpx 0;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
overflow: hidden;
&.mt { margin-top: 32rpx; }
}
.menu-item {
height: 112rpx;
padding: 0 40rpx;
display: flex;
align-items: center;
border-bottom: 1rpx solid #F0E0D6;
&:last-child { border-bottom: none; }
&:active { background: #FFF8F0; }
}
.mi-label { flex: 1; font-size: 28rpx; font-weight: 500; color: #2D1B1B; }
.mi-extra { font-size: 28rpx; color: #FF8C69; font-weight: 600; margin-right: 16rpx; }
.about-title {
font-size: 40rpx;
font-weight: 600;
color: #FF8C69;
display: block;
text-align: center;
}
.about-version {
font-size: 24rpx;
color: #BFB3B3;
display: block;
text-align: center;
margin: 8rpx 0 24rpx;
}
.about-desc {
font-size: 28rpx;
color: #8B7E7E;
display: block;
text-align: center;
margin-bottom: 8rpx;
}
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal {
width: 600rpx;
background: #FFFFFF;
border-radius: 32rpx;
padding: 48rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.modal-input {
height: 96rpx;
background: #FFF8F0;
border-radius: 24rpx;
padding: 0 32rpx;
font-size: 28rpx;
margin-bottom: 32rpx;
}
.modal-btns {
display: flex;
gap: 24rpx;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
&.cancel {
background: #FFF8F0;
color: #8B7E7E;
}
&.confirm {
background: linear-gradient(135deg, #FF8C69, #E67355);
color: #FFFFFF;
}
}
</style>

View File

@@ -0,0 +1,371 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">统计分析</text>
<view class="month-picker">
<view class="arrow" @tap="prevMonth">
<SvgIcon name="arrowLeft" :size="32" color="#8B7E7E" />
</view>
<text class="month-text">{{ formatMonth(currentMonth) }}</text>
<view class="arrow" :class="{ disabled: isCurrentMonth }" @tap="nextMonth">
<SvgIcon name="arrowRight" :size="32" :color="isCurrentMonth ? '#E0D6D0' : '#8B7E7E'" />
</view>
</view>
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: tabType === 'expense' }" @tap="tabType = 'expense'">支出</view>
<view class="tab" :class="{ active: tabType === 'income' }" @tap="tabType = 'income'">收入</view>
</view>
</view>
<view class="overview-card">
<view class="ov-item">
<text class="ov-label">{{ tabType === 'expense' ? '总支出' : '总收入' }}</text>
<text class="ov-value" v-if="!loading">{{ formatAmount(tabType === 'expense' ? overview.expense : overview.income) }}</text>
<Skeleton v-else width="160rpx" height="40rpx" variant="rect" />
</view>
<view class="ov-item">
<text class="ov-label">日均</text>
<text class="ov-value" v-if="!loading">{{ formatAmount(overview.daily) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
<view class="ov-item">
<text class="ov-label">笔数</text>
<text class="ov-value" v-if="!loading">{{ overview.count }}</text>
<Skeleton v-else width="80rpx" height="40rpx" variant="rect" />
</view>
</view>
<view class="chart-card" v-if="!loading && categoryStats.length > 0">
<text class="chart-title">分类占比</text>
<view class="chart-legend">
<view v-for="item in categoryStats" :key="item.id" class="legend-item">
<view class="legend-dot" :style="{ background: item.color }"></view>
<text class="legend-text">{{ item.name }} {{ formatAmount(item.amount) }}</text>
</view>
</view>
</view>
<view class="chart-card" v-if="loading">
<text class="chart-title">分类占比</text>
<view class="skeleton-legend">
<Skeleton v-for="i in 4" :key="i" width="200rpx" height="28rpx" variant="text" />
</view>
</view>
<view class="state-box" v-if="loadError">
<SvgIcon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败请稍后重试</text>
</view>
<view class="empty" v-if="!loading && !loadError && categoryStats.length === 0">
<text class="empty-text">暂无{{ tabType === 'expense' ? '支出' : '收入' }}数据</text>
</view>
<view class="chart-card" v-if="!loading && trendData.length > 0">
<text class="chart-title">每日趋势</text>
<view class="trend-list">
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
<text class="trend-date">{{ point.date.slice(8) }}</text>
<view class="trend-bar-bg">
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
</view>
<text class="trend-amount">{{ formatAmount(point.amount) }}</text>
</view>
</view>
</view>
<view class="chart-card" v-if="loading">
<text class="chart-title">每日趋势</text>
<view class="trend-list">
<view v-for="i in 5" :key="i" class="skeleton-trend">
<Skeleton width="48rpx" height="24rpx" variant="text" />
<Skeleton width="100%" height="12rpx" variant="text" />
<Skeleton width="120rpx" height="24rpx" variant="text" />
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats'
import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
const statsStore = useStatsStore()
const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense')
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const categoryStats = ref<any[]>([])
const trendData = ref<any[]>([])
const loading = ref(true)
const loadError = ref(false)
const isCurrentMonth = computed(() => currentMonth.value === getCurrentMonth())
onMounted(() => loadData())
// Only re-fetch category/trend on tab switch; re-fetch all on month change
watch(currentMonth, () => loadData())
watch(tabType, () => loadTabData())
async function loadData() {
try {
loading.value = true
loadError.value = false
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value)
])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
console.error('Stats load error:', e)
loadError.value = true
} finally {
loading.value = false
}
}
// Only re-fetch category/trend data (not overview) when tab switches
async function loadTabData() {
try {
await Promise.all([
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value)
])
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
console.error('Stats tab load error:', e)
}
}
onPullDownRefresh(() => {
loadData().finally(() => uni.stopPullDownRefresh())
})
function prevMonth() {
const [y, m] = currentMonth.value.split('-').map(Number)
currentMonth.value = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
}
function nextMonth() {
if (isCurrentMonth.value) return
const [y, m] = currentMonth.value.split('-').map(Number)
currentMonth.value = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`
}
function getBarWidth(amount: number) {
const max = Math.max(...trendData.value.map(t => t.amount), 1)
return (amount / max) * 100
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.status-bar {
background: #FFF8F0;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
}
.month-picker {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
padding: 16rpx 0;
}
.arrow {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&.disabled { opacity: 0.5; }
}
.month-text {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-weight: 500;
color: #2D1B1B;
}
.tabs-wrap { display: flex; justify-content: center; margin: 16rpx 0 32rpx; }
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
&.active {
background: #FFFFFF;
color: #FF8C69;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
}
}
.overview-card, .chart-card {
margin: 0 40rpx 32rpx;
padding: 40rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
}
.overview-card {
display: grid;
grid-template-columns: repeat(3, 1fr);
text-align: center;
}
.ov-label {
font-size: 24rpx;
color: #8B7E7E;
display: block;
margin-bottom: 12rpx;
}
.ov-value {
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
font-weight: 600;
color: #2D1B1B;
}
.chart-title {
font-size: 32rpx;
font-weight: 500;
color: #2D1B1B;
display: block;
margin-bottom: 24rpx;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16rpx 32rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 12rpx;
}
.legend-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
}
.legend-text { font-size: 24rpx; color: #8B7E7E; }
.empty {
text-align: center;
padding: 80rpx 0;
}
.empty-text { font-size: 28rpx; color: #BFB3B3; }
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 80rpx 0;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
}
.trend-list { display: flex; flex-direction: column; gap: 16rpx; }
.skeleton-legend {
display: flex;
flex-wrap: wrap;
gap: 16rpx 32rpx;
}
.skeleton-trend {
display: flex;
align-items: center;
gap: 16rpx;
}
.trend-item {
display: flex;
align-items: center;
gap: 16rpx;
}
.trend-date {
width: 48rpx;
font-size: 24rpx;
color: #8B7E7E;
text-align: right;
}
.trend-bar-bg {
flex: 1;
height: 12rpx;
border-radius: 6rpx;
background: #F0E0D6;
overflow: hidden;
}
.trend-bar {
height: 100%;
border-radius: 6rpx;
background: linear-gradient(90deg, #FF8C69, #FFB89A);
transition: width 0.6s ease-out;
}
.trend-amount {
width: 120rpx;
font-family: 'Fredoka', sans-serif;
font-size: 24rpx;
font-weight: 500;
color: #2D1B1B;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { request } from '@/utils/request'
import { getCurrentMonth } from '@/utils/format'
export interface Budget {
id: number
amount: number
month: string
}
export const useBudgetStore = defineStore('budget', () => {
const budget = ref<Budget | null>(null)
const loading = ref(false)
async function fetchBudget(month?: string) {
loading.value = true
try {
budget.value = await request<Budget>({
url: '/budget',
data: { month: month || getCurrentMonth() }
})
} catch {
budget.value = null
} finally {
loading.value = false
}
}
async function setBudget(amount: number, month?: string) {
await request({
url: '/budget',
method: 'POST',
data: { amount, month: month || getCurrentMonth() }
})
await fetchBudget(month)
}
return { budget, loading, fetchBudget, setBudget }
})

View File

@@ -0,0 +1,54 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { request } from '@/utils/request'
export interface Category {
id: number
name: string
icon: string
color: string
type: 'expense' | 'income'
sort_order: number
is_custom: number
}
export const useCategoryStore = defineStore('category', () => {
const categories = ref<Category[]>([])
const loading = ref(false)
async function fetchCategories() {
loading.value = true
try {
categories.value = await request<Category[]>({ url: '/categories' })
} catch {
categories.value = []
} finally {
loading.value = false
}
}
function getByType(type: 'expense' | 'income') {
return categories.value.filter(c => c.type === type)
}
function getById(id: number) {
return categories.value.find(c => c.id === id)
}
async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) {
const result = await request<{ id: number }>({
url: '/categories',
method: 'POST',
data
})
await fetchCategories()
return result.id
}
async function deleteCategory(id: number) {
await request({ url: `/categories/${id}`, method: 'DELETE' })
await fetchCategories()
}
return { categories, loading, fetchCategories, getByType, getById, addCategory, deleteCategory }
})

View File

@@ -0,0 +1,66 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { request } from '@/utils/request'
import { getCurrentMonth } from '@/utils/format'
export interface Overview {
expense: number
income: number
count: number
daily: number
}
export interface CategoryStat {
id: number
name: string
icon: string
color: string
amount: number
count: number
}
export interface TrendPoint {
date: string
amount: number
}
export const useStatsStore = defineStore('stats', () => {
const overview = ref<Overview>({ expense: 0, income: 0, count: 0, daily: 0 })
const categoryStats = ref<CategoryStat[]>([])
const trendData = ref<TrendPoint[]>([])
async function fetchOverview(month?: string) {
try {
overview.value = await request<Overview>({
url: '/stats/overview',
data: { month: month || getCurrentMonth() }
})
} catch {
overview.value = { expense: 0, income: 0, count: 0, daily: 0 }
}
}
async function fetchCategoryStats(month?: string, type: string = 'expense') {
try {
categoryStats.value = await request<CategoryStat[]>({
url: '/stats/category',
data: { month: month || getCurrentMonth(), type }
})
} catch {
categoryStats.value = []
}
}
async function fetchTrend(month?: string, type: string = 'expense') {
try {
trendData.value = await request<TrendPoint[]>({
url: '/stats/trend',
data: { month: month || getCurrentMonth(), type }
})
} catch {
trendData.value = []
}
}
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend }
})

View File

@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { request } from '@/utils/request'
export interface Transaction {
id: number
amount: number
type: 'expense' | 'income'
category_id: number
note?: string
date: string
category_name?: string
category_icon?: string
category_color?: string
}
export interface TransactionList {
list: Transaction[]
total: number
page: number
pageSize: number
}
export const useTransactionStore = defineStore('transaction', () => {
const transactions = ref<Transaction[]>([])
const total = ref(0)
const loading = ref(false)
const currentPage = ref(1)
async function fetchTransactions(params?: { type?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }) {
loading.value = true
try {
const data = await request<TransactionList>({
url: '/transactions',
data: params
})
transactions.value = data.list
total.value = data.total
currentPage.value = data.page
} catch {
transactions.value = []
total.value = 0
} finally {
loading.value = false
}
}
async function addTransaction(data: { amount: number; type: string; category_id?: number; note?: string; date: string }) {
const result = await request<{ id: number }>({
url: '/transactions',
method: 'POST',
data
})
return result.id
}
async function updateTransaction(id: number, data: { amount: number; type: string; category_id?: number; note?: string; date: string }) {
await request({ url: `/transactions/${id}`, method: 'PUT', data })
}
async function deleteTransaction(id: number) {
await request({ url: `/transactions/${id}`, method: 'DELETE' })
}
async function fetchTransactionById(id: number): Promise<Transaction | undefined> {
try {
return await request<Transaction>({ url: `/transactions/${id}` })
} catch {
return undefined
}
}
return { transactions, total, loading, currentPage, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById }
})

35
client/src/uni.scss Normal file
View File

@@ -0,0 +1,35 @@
/* Design Tokens */
$primary: #FF8C69;
$primary-light: #FFB89A;
$primary-dark: #E67355;
$secondary: #6C9BCF;
$accent: #FFD166;
$bg: #FFF8F0;
$surface: #FFFFFF;
$surface-warm: #FFF0E6;
$text: #2D1B1B;
$text-sec: #8B7E7E;
$text-muted: #BFB3B3;
$success: #7BC67E;
$warning: #FFB347;
$danger: #FF6B6B;
$border: #F0E0D6;
$shadow-clay: 4px 4px 12px rgba(45, 27, 27, 0.08), inset -2px -2px 6px rgba(45, 27, 27, 0.04);
$shadow-clay-hover: 6px 6px 16px rgba(45, 27, 27, 0.12), inset -2px -2px 6px rgba(45, 27, 27, 0.04);
$shadow-clay-pressed: inset 2px 2px 6px rgba(45, 27, 27, 0.1);
$radius-sm: 8rpx;
$radius-md: 12rpx;
$radius-lg: 16rpx;
$radius-xl: 20rpx;
$radius-full: 9999rpx;
/* uCharts theme */
page {
--u-charts-color-0: #FF8C69;
--u-charts-color-1: #FFD166;
--u-charts-color-2: #6C9BCF;
--u-charts-color-3: #7BC67E;
--u-charts-color-4: #BFB3B3;
}

View 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, ',')
}

View 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
}

View File

@@ -0,0 +1,10 @@
let statusBarHeight = 0
try {
const info = uni.getSystemInfoSync()
statusBarHeight = info.statusBarHeight || 0
} catch {
statusBarHeight = 20
}
export { statusBarHeight }

19
client/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"moduleResolution": "node",
"esModuleInterop": true,
"sourceMap": true,
"skipLibCheck": true,
"lib": ["esnext", "dom"],
"types": ["@dcloudio/types"],
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

6
client/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import uni from '@dcloudio/vite-plugin-uni'
export default defineConfig({
plugins: [uni()],
})

5295
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

14
deploy/backup.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
# 数据库备份脚本 (每天执行)
BACKUP_DIR="/var/backups/xiaocai"
DATE=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
mysqldump -u xiaocai -pxiaocai123 xiaocai | gzip > "$BACKUP_DIR/xiaocai_$DATE.sql.gz"
# 保留最近 7 天的备份
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete
echo "备份完成: $BACKUP_DIR/xiaocai_$DATE.sql.gz"

56
deploy/deploy.sh Normal file
View File

@@ -0,0 +1,56 @@
#!/bin/bash
# 小菜记账部署脚本
set -e
SERVER_DIR="/var/www/xiaocai"
LOG_DIR="/var/log/xiaocai"
echo "=== 开始部署 ==="
# 进入项目目录
cd "$(dirname "$0")/.."
# 构建服务端
echo "1/4 构建服务端..."
cd server
npm install
npm run build
cd ..
# 复制文件到服务器目录
echo "2/4 复制文件..."
cp -r server/dist "$SERVER_DIR/"
cp -r server/node_modules "$SERVER_DIR/"
cp server/package.json "$SERVER_DIR/"
cp deploy/ecosystem.config.js "$SERVER_DIR/"
# 初始化数据库
echo "3/4 初始化数据库..."
cd "$SERVER_DIR"
mysql -u xiaocai -pxiaocai123 xiaocai < dist/db/schema.sql
mysql -u xiaocai -pxiaocai123 xiaocai < dist/db/seed.sql 2>/dev/null || true
cd -
# 配置 Nginx
echo "4/4 配置 Nginx..."
sudo cp deploy/nginx.conf /etc/nginx/sites-available/xiaocai
sudo ln -sf /etc/nginx/sites-available/xiaocai /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
# 重启服务
echo "重启 PM2 服务..."
cd "$SERVER_DIR"
pm2 delete xiaocai-server 2>/dev/null || true
pm2 start ecosystem.config.js
pm2 save
echo ""
echo "=== 部署完成 ==="
echo "服务地址: http://your-domain.com"
echo "API 健康检查: http://your-domain.com/api/health"
echo ""
echo "查看日志: pm2 logs xiaocai-server"
echo "查看状态: pm2 status"

View File

@@ -0,0 +1,19 @@
module.exports = {
apps: [
{
name: 'xiaocai-server',
script: './dist/index.js',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '300M',
env: {
NODE_ENV: 'production',
PORT: 3000
},
error_file: '/var/log/xiaocai/error.log',
out_file: '/var/log/xiaocai/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss'
}
]
}

26
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 80;
server_name your-domain.com;
# 小程序 API
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 健康检查
location /health {
proxy_pass http://127.0.0.1:3000;
}
# SSL 配置 (上线前取消注释)
# listen 443 ssl;
# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# HTTP 重定向到 HTTPS
# return 301 https://$host$request_uri;
}

57
deploy/setup.sh Normal file
View File

@@ -0,0 +1,57 @@
#!/bin/bash
# 小菜记账服务器初始化脚本
# 适用于 Ubuntu 22.04 LTS + 阿里云 2H2G
set -e
echo "=== 小菜记账服务器初始化 ==="
# 更新系统
echo "1/8 更新系统包..."
sudo apt update && sudo apt upgrade -y
# 安装 Node.js 20
echo "2/8 安装 Node.js 20..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# 安装 MySQL 8.0
echo "3/8 安装 MySQL 8.0..."
sudo apt install -y mysql-server
# 安全配置 MySQL
echo "4/8 配置 MySQL..."
sudo mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root_password';"
sudo mysql -e "CREATE DATABASE IF NOT EXISTS xiaocai DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER IF NOT EXISTS 'xiaocai'@'localhost' IDENTIFIED BY 'xiaocai123';"
sudo mysql -e "GRANT ALL PRIVILEGES ON xiaocai.* TO 'xiaocai'@'localhost';"
sudo mysql -e "FLUSH PRIVILEGES;"
# 安装 Nginx
echo "5/8 安装 Nginx..."
sudo apt install -y nginx
# 安装 PM2
echo "6/8 安装 PM2..."
sudo npm install -g pm2
# 创建部署目录
echo "7/8 创建部署目录..."
sudo mkdir -p /var/www/xiaocai
sudo mkdir -p /var/log/xiaocai
sudo chown -R $USER:$USER /var/www/xiaocai
# 配置防火墙
echo "8/8 配置防火墙..."
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
echo ""
echo "=== 服务器初始化完成 ==="
echo "MySQL root 密码: root_password"
echo "MySQL xiaocai 密码: xiaocai123"
echo "请修改 deploy/ecosystem.config.js 中的数据库密码"
echo ""
echo "下一步: ./deploy.sh"

490
docs/development-plan.md Normal file
View File

@@ -0,0 +1,490 @@
# 小菜记账 — 小程序开发方案
> 版本: v1.0 | 日期: 2026-05-27
> UI 原型稿: `docs/mockup/index.html`
---
## 一、项目概述
| 项目 | 说明 |
|------|------|
| 产品名称 | 小菜记账 |
| 产品类型 | 个人记账微信小程序 |
| AppID | wx854804dff2928c53 |
| 设计风格 | Claymorphism (粘土风格) |
| 目标用户 | 18-35岁年轻人注重生活品质 |
---
## 二、技术架构
### 前端 (小程序)
| 技术 | 选型 | 说明 |
|------|------|------|
| 框架 | Uni-app (Vue 3 + Composition API) | 一套代码,跨端运行 |
| 状态管理 | Pinia | 轻量、TypeScript 友好 |
| 样式 | UnoCSS + SCSS | 原子化 + 变量系统 |
| 图标 | Lucide Icons (SVG) | 统一图标集 |
| 图表 | uCharts (qiun-data-charts) | 小程序端图表组件 |
| 字体 | Fredoka + Nunito | 通过 @font-face 加载 |
### 后端 (阿里云 2H2G)
| 技术 | 选型 | 说明 |
|------|------|------|
| 运行时 | Node.js 20 LTS | 轻量高效 |
| 框架 | Express.js | 简单可靠 |
| 数据库 | MySQL 8.0 | 成熟稳定,生态丰富 |
| ORM | Prisma | 类型安全,迁移方便 |
| 缓存 | 无 (内存足够) | 2G 内存可支撑 |
| 部署 | PM2 + Nginx | 进程管理 + 反向代理 |
---
## 三、功能清单
### P0 (MVP - 第一版)
| 模块 | 功能 | 说明 |
|------|------|------|
| 记账 | 添加支出/收入 | 金额、分类、日期、备注 |
| 记账 | 分类选择 | 预设 12 个分类 + 自定义 |
| 首页 | 月度总览 | 本月支出、预算进度 |
| 首页 | 近期账单 | 最近 20 条交易 |
| 统计 | 分类占比 | 环形图 |
| 统计 | 每日趋势 | 折线图 |
| 我的 | 预算设置 | 月度预算 |
| 我的 | 数据导出 | CSV 格式 |
### P1 (第二版)
| 模块 | 功能 | 说明 |
|------|------|------|
| 记账 | 图片识别 | 拍照识别金额 |
| 统计 | 周/月/年切换 | 多维度分析 |
| 我的 | 数据备份 | 云同步 |
| 我的 | 深色模式 | 主题切换 |
### P2 (第三版)
| 模块 | 功能 | 说明 |
|------|------|------|
| 高级 | 多账本 | 不同场景分类 |
| 高级 | 预算提醒 | 超支通知 |
| 高级 | 团队记账 | AA 记账 |
---
## 四、数据库设计
### MySQL 安装 (阿里云服务器)
```bash
# 安装 MySQL 8.0
sudo apt update
sudo apt install -y mysql-server
# 安全配置
sudo mysql_secure_installation
# 创建数据库和用户
sudo mysql -u root -p
CREATE DATABASE xiaocai DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'xiaocai'@'localhost' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON xiaocai.* TO 'xiaocai'@'localhost';
FLUSH PRIVILEGES;
EXIT;
```
### 核心表
```sql
-- 分类表
CREATE TABLE categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
icon VARCHAR(50) NOT NULL,
color VARCHAR(20) NOT NULL,
type ENUM('expense', 'income') NOT NULL,
sort_order INT DEFAULT 0,
is_custom TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 交易表
CREATE TABLE transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
amount INT NOT NULL COMMENT '单位:分',
type ENUM('expense', 'income') NOT NULL,
category_id INT,
note VARCHAR(200),
date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_date (date),
INDEX idx_type (type),
FOREIGN KEY (category_id) REFERENCES categories(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 预算表
CREATE TABLE budgets (
id INT AUTO_INCREMENT PRIMARY KEY,
amount INT NOT NULL COMMENT '单位:分',
month VARCHAR(7) NOT NULL COMMENT '格式2026-05',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_month (month)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户表 (预留微信登录)
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(100) UNIQUE,
session_key VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 预设分类数据
| 分类 | 图标 (Lucide) | 颜色 | 类型 |
|------|---------------|------|------|
| 餐饮 | Coffee | #FF8C69 | 支出 |
| 饮品 | Coffee | #FF8C69 | 支出 |
| 购物 | ShoppingBag | #FFB347 | 支出 |
| 交通 | Car | #6C9BCF | 支出 |
| 住房 | Home | #7BC67E | 支出 |
| 医疗 | Heart | #FF6B6B | 支出 |
| 教育 | BookOpen | #6C9BCF | 支出 |
| 娱乐 | Film | #B39DDB | 支出 |
| 服饰 | Shirt | #FFD166 | 支出 |
| 美容 | Smile | #FF8C69 | 支出 |
| 通讯 | Smartphone | #6C9BCF | 支出 |
| 其他 | MoreHorizontal | #BFB3B3 | 支出 |
| 工资 | DollarSign | #7BC67E | 收入 |
| 副业 | TrendingUp | #7BC67E | 收入 |
| 理财 | PieChart | #7BC67E | 收入 |
| 其他收入 | Plus | #BFB3B3 | 收入 |
---
## 五、API 设计
### 基础信息
- 基础 URL: `https://your-domain.com/api`
- 认证: 微信登录 (code2session)
- 响应格式: JSON
### 接口列表
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /auth/login | 微信登录 |
| GET | /categories | 获取分类列表 |
| POST | /transactions | 添加交易 |
| GET | /transactions | 查询交易列表 |
| PUT | /transactions/:id | 更新交易 |
| DELETE | /transactions/:id | 删除交易 |
| GET | /stats/overview | 月度概览 |
| GET | /stats/category | 分类统计 |
| GET | /stats/trend | 每日趋势 |
| GET | /budget | 获取预算 |
| POST | /budget | 设置预算 |
| GET | /export/csv | 导出 CSV |
### 响应示例
```json
// 成功
{
"code": 0,
"data": { ... },
"message": "success"
}
// 失败
{
"code": 40001,
"data": null,
"message": "参数错误"
}
```
---
## 六、项目结构
```
xiaocai/
├── client/ # 小程序端
│ ├── src/
│ │ ├── pages/
│ │ │ ├── index/ # 首页
│ │ │ ├── add/ # 记账
│ │ │ ├── stats/ # 统计
│ │ │ ├── bills/ # 账单
│ │ │ └── profile/ # 我的
│ │ ├── components/
│ │ │ ├── AmountCard/
│ │ │ ├── TransactionItem/
│ │ │ ├── CategoryIcon/
│ │ │ ├── BudgetBar/
│ │ │ ├── BottomNav/
│ │ │ ├── Numpad/
│ │ │ └── Chart/
│ │ ├── stores/
│ │ │ ├── transaction.ts
│ │ │ ├── category.ts
│ │ │ └── budget.ts
│ │ ├── utils/
│ │ │ ├── format.ts
│ │ │ ├── storage.ts
│ │ │ └── request.ts
│ │ ├── static/
│ │ │ └── fonts/
│ │ ├── App.vue
│ │ ├── main.ts
│ │ ├── manifest.json
│ │ ├── pages.json
│ │ └── uni.scss
│ ├── package.json
│ └── vite.config.ts
├── server/ # 后端
│ ├── src/
│ │ ├── routes/
│ │ │ ├── auth.ts
│ │ │ ├── transaction.ts
│ │ │ ├── category.ts
│ │ │ ├── stats.ts
│ │ │ └── budget.ts
│ │ ├── db/
│ │ │ ├── schema.sql
│ │ │ └── seed.sql
│ │ ├── middleware/
│ │ │ └── auth.ts
│ │ └── index.ts
│ ├── data/ # 数据库迁移文件
│ ├── package.json
│ └── tsconfig.json
├── docs/ # 文档
│ ├── mockup/ # UI 原型
│ ├── ui-design.md # 设计规格
│ └── development-plan.md # 本文件
└── deploy/ # 部署脚本
├── nginx.conf
├── ecosystem.config.js # PM2 配置
└── setup.sh # 服务器初始化
```
---
## 七、开发计划
### 阶段一: 基础搭建 (第 1 周)
| 任务 | 说明 | 产出 |
|------|------|------|
| 项目初始化 | Uni-app 项目 + 后端项目 | 可运行的空项目 |
| 数据库设计 | MySQL 建表 + 种子数据 | 数据库就绪 |
| 微信登录 | code2session 对接 | 登录流程 |
| API 框架 | Express + 中间件 | API 可调用 |
### 阶段二: 核心功能 (第 2-3 周)
| 任务 | 说明 | 产出 |
|------|------|------|
| 记账功能 | 添加/编辑/删除交易 | 核心记账流程 |
| 分类管理 | 预设分类 + 自定义 | 12 个分类 |
| 首页展示 | 月度总览 + 账单列表 | 首页完整 |
| 预算功能 | 设置/展示预算 | 预算进度条 |
### 阶段三: 统计分析 (第 4 周)
| 任务 | 说明 | 产出 |
|------|------|------|
| 分类统计 | 环形图 + 排行 | 分类占比 |
| 趋势分析 | 折线图 | 每日趋势 |
| 数据导出 | CSV 导出 | 可导出数据 |
### 阶段四: 优化上线 (第 5 周)
| 任务 | 说明 | 产出 |
|------|------|------|
| UI 细节 | 动画、交互优化 | 精细化 |
| 性能优化 | 首屏加载、缓存 | 流畅体验 |
| 服务器部署 | Nginx + PM2 | 上线运行 |
| 微信审核 | 提交审核 | 正式发布 |
---
## 八、服务器部署方案
### 环境配置
```bash
# 系统: Ubuntu 22.04 LTS
# 配置: 2 核 CPU, 2GB 内存
# 安装 Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# 安装 MySQL 8.0
sudo apt install -y mysql-server
sudo mysql_secure_installation
# 安装 Nginx
sudo apt install -y nginx
# 安装 PM2
sudo npm install -g pm2
# 创建部署目录
sudo mkdir -p /var/www/xiaocai
sudo chown $USER:$USER /var/www/xiaocai
```
### Nginx 配置
```nginx
server {
listen 80;
server_name your-domain.com;
# 小程序 API
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# SSL (上线前配置)
# listen 443 ssl;
# ssl_certificate /path/to/cert.pem;
# ssl_certificate_key /path/to/key.pem;
}
```
### PM2 配置
```javascript
// ecosystem.config.js
module.exports = {
apps: [{
name: 'xiaocai-server',
script: './dist/index.js',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '300M',
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
};
```
### 性能预估 (2H2G)
| 指标 | 预估 |
|------|------|
| 并发用户 | 50-100 |
| QPS | 200-500 |
| 内存占用 | Node.js 150MB + MySQL 300MB |
| 响应时间 | < 100ms |
| 数据库大小 | < 100MB/年 |
**内存分配**: MySQL 约 300MBNode.js 约 150MB系统约 500MB剩余可用约 1GB。
---
## 九、微信小程序配置
### manifest.json
```json
{
"mp-weixin": {
"appid": "wx854804dff2928c53",
"setting": {
"urlCheck": true,
"es6": true,
"postcss": true,
"minified": true
},
"usingComponents": true,
"permission": {
"scope.userLocation": {
"desc": "用于获取您的位置信息"
}
}
}
}
```
### pages.json
```json
{
"pages": [
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "小菜记账" } },
{ "path": "pages/add/index", "style": { "navigationBarTitleText": "记一笔" } },
{ "path": "pages/stats/index", "style": { "navigationBarTitleText": "统计分析" } },
{ "path": "pages/bills/index", "style": { "navigationBarTitleText": "全部账单" } },
{ "path": "pages/profile/index", "style": { "navigationBarTitleText": "我的" } }
],
"tabBar": {
"color": "#BFB3B3",
"selectedColor": "#FF8C69",
"list": [
{ "pagePath": "pages/index/index", "text": "首页", "iconPath": "static/icons/home.png", "selectedIconPath": "static/icons/home-active.png" },
{ "pagePath": "pages/stats/index", "text": "统计", "iconPath": "static/icons/chart.png", "selectedIconPath": "static/icons/chart-active.png" },
{ "pagePath": "pages/bills/index", "text": "账单", "iconPath": "static/icons/bill.png", "selectedIconPath": "static/icons/bill-active.png" },
{ "pagePath": "pages/profile/index", "text": "我的", "iconPath": "static/icons/user.png", "selectedIconPath": "static/icons/user-active.png" }
]
}
}
```
---
## 十、风险与应对
| 风险 | 影响 | 应对方案 |
|------|------|----------|
| 2G 内存不足 | 服务崩溃 | 监控内存,必要时升级配置 |
| 微信审核不通过 | 延迟上线 | 提前了解审核规范 |
| 数据丢失 | 用户体验差 | 每日自动备份 MySQL (mysqldump) |
| 并发量突增 | 响应变慢 | PM2 自动重启 + 限流 |
---
## 十一、预算
| 项目 | 费用 |
|------|------|
| 阿里云服务器 | 已有 (2H2G) |
| 域名 | ¥55/年 (.com) |
| SSL 证书 | 免费 (Let's Encrypt) |
| 微信认证 | ¥300/年 (企业) |
| **总计** | ¥355/年 |
---
## 十二、下一步行动
1. **立即开始**: 初始化 Uni-app 项目 + 后端项目
2. **本周完成**: 数据库设计 + 微信登录 + API 框架
3. **下周目标**: 完成记账核心功能
4. **月底目标**: 完成全部 P0 功能,提交微信审核
---
*文档版本: v1.0 | 最后更新: 2026-05-27*

1638
docs/mockup/index.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,354 @@
# 小菜记账 — 设计规格书
## 概述
**产品名称**: 小菜记账 (Xiaocai Accounting)
**产品类型**: 个人记账小程序
**设计方向**: 可爱 × 高级 (Cute × Premium)
**设计风格**: Claymorphism (软3D粘土风格) + 极简主义融合
**目标平台**: 移动端优先 (微信小程序 / Web App)
---
## 设计理念
"小菜"暗示温暖、家常、轻松。设计需要传递 **「记账也可以很可爱」** 的情感,同时通过精致的细节呈现 **高级感**
### 品牌人格
- **友好温暖** — 不冰冷的财务工具,是陪你记账的小伙伴
- **精致克制** — 可爱但不幼稚,高级但不冷淡
- **轻松治愈** — 让记账从负担变成享受
---
## 色彩系统
| 角色 | 色值 | 用途 |
|------|------|------|
| Primary | `#FF8C69` | 主色调,珊瑚橙,温暖活泼 |
| Primary Light | `#FFB89A` | 浅色变体,背景装饰 |
| Primary Dark | `#E67355` | 深色变体,按下状态 |
| Secondary | `#6C9BCF` | 辅助色,柔和蓝,收入/信任 |
| Accent | `#FFD166` | 强调色,暖金,高亮点缀 |
| Background | `#FFF8F0` | 全局背景,暖奶油色 |
| Surface | `#FFFFFF` | 卡片/面板背景 |
| Surface Warm | `#FFF0E6` | 暖色卡片背景 |
| Text Primary | `#2D1B1B` | 主文字,暖深棕 |
| Text Secondary | `#8B7E7E` | 辅助文字 |
| Text Muted | `#BFB3B3` | 禁用/占位文字 |
| Success | `#7BC67E` | 收入/成功,软绿 |
| Warning | `#FFB347` | 提醒,暖橘 |
| Danger | `#FF6B6B` | 超支/删除,软红 |
| Border | `#F0E0D6` | 分割线/边框 |
### 色彩使用原则
- 主色调占界面约 15-20%,避免过度使用
- 背景保持温暖但不刺眼,`#FFF8F0` 比纯白更有亲和力
- 文字对比度确保 WCAG AA (4.5:1 以上)
- 深棕色文字替代纯黑,与整体暖色调统一
---
## 字体系统
| 用途 | 字体 | 备选 | 权重 |
|------|------|------|------|
| 标题 | Fredoka | PingFang SC (中文) | 500-700 |
| 正文 | Nunito | PingFang SC (中文) | 400-500 |
| 数字/金额 | Fredoka | - | 600-700 |
| 特殊标注 | Caveat (手写体) | - | 400-600 |
### 字号规范
| 级别 | 大小 | 用途 |
|------|------|------|
| H1 | 28px/1.3 | 页面主标题 |
| H2 | 22px/1.3 | 区块标题 |
| H3 | 18px/1.4 | 卡片标题 |
| Body L | 16px/1.6 | 正文 |
| Body | 14px/1.6 | 辅助正文 |
| Caption | 12px/1.5 | 说明文字 |
| Amount XL | 36px/1.2 | 首页总金额 |
| Amount L | 24px/1.2 | 列表金额 |
---
## UI 组件规范
### 粘土风格核心参数
- `border-radius`: 16-24px (卡片), 12-16px (按钮)
- `border`: 2-3px solid (深色边框强调立体感)
- `box-shadow`: 内阴影 + 外阴影双层结构
- 外阴影: `4px 4px 12px rgba(45, 27, 27, 0.08)`
- 内阴影: `inset -2px -2px 6px rgba(45, 27, 27, 0.04)`
- 按钮按下: `inset 2px 2px 6px rgba(45, 27, 27, 0.1)` + scale(0.97)
### 按钮
| 类型 | 样式 |
|------|------|
| Primary | 珊瑚橙背景 + 白字 + 粘土阴影 + 圆角16px |
| Secondary | 白色背景 + 3px珊瑚橙边框 + 粘土阴影 |
| Ghost | 无背景 + 珊瑚橙文字 + hover浅色背景 |
| Danger | 软红背景 + 白字 |
### 卡片
- 白色背景 + 粘土阴影
- 圆角 20px
- 内边距 16-20px
- 点击态: 轻微下沉 (translateY 2px + 阴影缩减)
### 输入框
- 圆角 14px
- 2px 边框 `#F0E0D6`,聚焦时变为 Primary
- 内阴影 `inset 1px 1px 4px rgba(45,27,27,0.04)`
- 高度 48px方便触摸
### 图标
- 使用 **Lucide Icons** (一致的 SVG 图标集)
- 尺寸: 24px (标准), 20px (小), 32px (大)
- 颜色跟随内容层级
---
## 页面结构
### 1. 首页 — 仪表盘
```
┌──────────────────────────┐
│ 👤 头像 小菜记账 📊 │ 顶栏
├──────────────────────────┤
│ 本月支出 │
│ ¥ 3,280.00 │ 金额大卡片 (粘土)
│ ━━━━━━━━━━━━━━ 65% │ 预算进度条
│ 预算剩余 ¥1,720 │
├──────────────────────────┤
│ [收入] [支出] [转账] │ 快捷操作 (3个大按钮)
├──────────────────────────┤
│ 近期账单 │
│ 🍜 午餐 -¥35 今天 │
│ ☕ 咖啡 -¥18 今天 │ 交易列表
│ 💰 工资 +¥8,000 昨天 │
│ 📦 快递 -¥28 昨天 │
├──────────────────────────┤
│ 🏠 📊 💰 👤 │ 底部导航
└──────────────────────────┘
```
### 2. 记账页 — 添加记录
- 大号金额输入区 (居中,大字体)
- 分类选择 (圆形图标网格,粘土风格)
- 日期选择
- 备注输入
- 支出/收入切换标签
### 3. 统计页 — 图表分析
- 月度总览卡片
- 分类饼图/环形图
- 日支出趋势折线图
- 分类排行榜
### 4. 我的 — 设置
- 用户头像和信息
- 预算设置
- 分类管理
- 数据导出
---
## 动效规范
| 类型 | 时长 | 缓动 | 说明 |
|------|------|------|------|
| 按钮按下 | 150ms | ease-out | 轻微下沉 + 缩放 |
| 页面切换 | 250ms | ease-in-out | 淡入 + 上移 8px |
| 数字变化 | 400ms | ease-out | 数字递增动画 |
| 列表出现 | 300ms | ease-out | 错位入场 (stagger 50ms) |
| 悬浮反馈 | 200ms | ease | 阴影加深 + 微上浮 |
### 动画原则
- 尊重 `prefers-reduced-motion`
- 不使用夸张弹跳 (粘土风格的自然按压感即可)
- 数字跳动使用 `font-variant-numeric: tabular-nums` 防止宽度变化
---
## 设计令牌系统
### 间距 (4px 基准)
| Token | 值 | 用途 |
|-------|-----|------|
| `--space-xs` | 4px | 图标与文字间距 |
| `--space-sm` | 8px | 紧凑内边距 |
| `--space-md` | 12px | 按钮内边距 |
| `--space-base` | 16px | 卡片内边距、列表间距 |
| `--space-lg` | 20px | 区块间距 |
| `--space-xl` | 24px | 页面内边距 |
| `--space-2xl` | 32px | 大区块间距 |
### 圆角
| Token | 值 | 用途 |
|-------|-----|------|
| `--radius-sm` | 8px | 标签、小按钮 |
| `--radius-md` | 12px | 输入框、普通按钮 |
| `--radius-lg` | 16px | 主按钮、分类图标 |
| `--radius-xl` | 20px | 卡片 |
| `--radius-full` | 9999px | 圆形头像、药丸标签 |
### 阴影 (粘土风格)
| Token | 值 | 用途 |
|-------|-----|------|
| `--shadow-clay` | `4px 4px 12px rgba(45,27,27,0.08), inset -2px -2px 6px rgba(45,27,27,0.04)` | 卡片默认 |
| `--shadow-clay-hover` | `6px 6px 16px rgba(45,27,27,0.12), inset -2px -2px 6px rgba(45,27,27,0.04)` | 卡片悬浮 |
| `--shadow-clay-pressed` | `inset 2px 2px 6px rgba(45,27,27,0.1)` | 按钮按下 |
| `--shadow-clay-float` | `8px 8px 24px rgba(45,27,27,0.1), inset -3px -3px 8px rgba(45,27,27,0.05)` | 浮动元素 |
---
## 组件库规范
### AmountCard (金额卡片)
显示金额的卡片容器,用于首页总览、统计概览等。
| Prop | 类型 | 说明 |
|------|------|------|
| `title` | string | 标题文字 |
| `amount` | number | 金额 (分) |
| `prefix` | `'+'` \| `'-'` \| `''` | 前缀符号 |
| `variant` | `'default'` \| `'warm'` \| `'primary'` | 样式变体 |
| `trend` | `{ value, direction }` | 趋势数据 |
变体:
- `default`: 白色背景 + 粘土阴影
- `warm`: 背景 `#FFF0E6`
- `primary`: 渐变 `#FF8C69 → #FFB89A`,文字白色
### TransactionItem (交易列表项)
| Prop | 类型 | 说明 |
|------|------|------|
| `icon` | string | Lucide 图标名 |
| `iconColor` | string | 图标颜色 |
| `iconBg` | string | 图标背景色 |
| `name` | string | 交易名称 |
| `category` | string | 分类名 |
| `time` | string | 时间 |
| `amount` | number | 金额 (分) |
| `type` | `'expense'` \| `'income'` | 类型 |
布局: 64px 高flex 对齐gap 12px。按下态: 背景 `#FFF8F0`100ms。
### CategoryIcon (分类图标)
| Prop | 类型 | 说明 |
|------|------|------|
| `name` | string | Lucide 图标名 |
| `size` | `'sm'` \| `'md'` \| `'lg'` | 尺寸 (20/24/28px) |
| `color` | string | 图标颜色 |
| `bgColor` | string | 背景色 |
| `selected` | boolean | 是否选中 |
尺寸映射:
- sm: 容器 32px图标 16px圆角 8px
- md: 容器 44px图标 22px圆角 12px
- lg: 容器 56px图标 28px圆角 14px
### BudgetBar (预算进度条)
| Prop | 类型 | 说明 |
|------|------|------|
| `total` | number | 预算总额 (分) |
| `used` | number | 已用金额 (分) |
| `showLabel` | boolean | 是否显示标签 |
轨道: 高 8px圆角 4px背景 `#F0E0D6`
填充: 渐变 `#FF8C69 → #FFB89A`,宽度 = used/total。
超支态: 填充色变为 `#FF6B6B`
动画: 宽度变化 600ms ease-out。
### BottomNav (底部导航栏)
| Prop | 类型 | 说明 |
|------|------|------|
| `current` | `'home'` \| `'stats'` \| `'profile'` | 当前页 |
| `onNavigate` | function | 导航回调 |
高度: 56px + safe-area。4 个导航项 + 中间浮动 "+" 按钮。
选中态: 图标填充 `#FF8C69`,文字 `#FF8C69` Nunito 600。
默认态: 图标线条 `#BFB3B3`,文字 `#BFB3B3` Nunito 400。
---
## 暗色模式 (v2 预留)
| Token | Light | Dark |
|-------|-------|------|
| Background | `#FFF8F0` | `#1A1412` |
| Surface | `#FFFFFF` | `#2D2422` |
| Surface Warm | `#FFF0E6` | `#3D2E2A` |
| Text Primary | `#2D1B1B` | `#F5EDE8` |
| Text Secondary | `#8B7E7E` | `#A89890` |
| Border | `#F0E0D6` | `#4A3C38` |
| Primary | `#FF8C69` | `#FF8C69` (不变) |
| Shadow | `rgba(45,27,27,0.08)` | `rgba(0,0,0,0.3)` |
---
## 技术选型建议
| 方案 | 技术栈 | 适用场景 |
|------|--------|----------|
| A | Uni-app (Vue3) + Pinia | 跨端小程序,一套代码多平台 |
| B | Taro (React) + Zustand | React 生态,跨端 |
| C | 原生微信小程序 | 纯微信生态,性能最优 |
| D | HTML + Tailwind CSS + Vanilla JS | Web App最快原型 |
**推荐 A (Uni-app)**: 跨端能力 + Vue 生态成熟 + 社区资源丰富,适合快速开发上线。
---
## 反模式 (避免)
- ❌ 使用 emoji 作为 UI 图标 → ✅ 使用 Lucide SVG 图标
- ❌ 纯黑或冷灰色文字 → ✅ 暖棕色调文字
- ❌ 扁平无层次 → ✅ 粘土阴影创造深度
- ❌ 过于幼稚 → ✅ 克制的圆角和色彩饱和度
- ❌ 文字过多 → ✅ 图标+数字为主,文字为辅
- ❌ 弹出式键盘遮挡 → ✅ 金额输入固定在键盘上方
- ❌ 忽略触摸区域 → ✅ 最小 44×44px 触摸目标
---
## 响应式与适配
### 断点
| 断点 | 宽度 | 设备 | 布局调整 |
|------|------|------|----------|
| xs | < 375px | 小屏手机 | 缩小间距,字号减 1px |
| sm | 375-414px | 标准手机 | 基准设计 |
| md | 415-768px | 大手机/小平板 | 内容区 max-width 480px 居中 |
| lg | > 768px | 平板/H5 | 双列布局,侧边导航 |
### 小程序 rpx 换算
设计稿 375px → 750rpx (1px = 2rpx)
| 设计稿 | rpx |
|--------|-----|
| 12px | 24rpx |
| 14px | 28rpx |
| 16px | 32rpx |
| 18px | 36rpx |
| 20px | 40rpx |
| 24px | 48rpx |
| 36px | 72rpx |
| 48px | 96rpx |
### H5 桌面端
- 内容区: `max-width: 420px`,居中
- 背景: `#F0E0D6` (深色底)
- 手机框: 圆角 32px + 阴影,模拟手机外观

1100
docs/ui-design.md Normal file

File diff suppressed because it is too large Load Diff

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!')

12
server/.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Server
PORT=3000
# MySQL
DB_HOST=your_mysql_host_here
DB_USER=your_mysql_user_here
DB_PASSWORD=your_mysql_password_here
DB_NAME=your_mysql_name_here
# WeChat Mini Program
WX_APPID=your_wx_appid_here
WX_SECRET=your_wx_secret_here

2236
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
server/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "xiaocai-server",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && shx cp src/db/*.sql dist/db/",
"start": "node dist/index.js",
"db:init": "tsx src/db/init.ts"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^4.18.2",
"mysql2": "^3.9.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.11.0",
"shx": "^0.4.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,17 @@
import mysql from 'mysql2/promise'
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
database: process.env.DB_NAME || 'xiaocai',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})
;(pool as any).on('error', (err: any) => {
console.error('[DB] Pool error:', err?.message || err)
})
export default pool

51
server/src/db/init.ts Normal file
View File

@@ -0,0 +1,51 @@
import mysql from 'mysql2/promise'
import pool from './connection'
import { readFileSync } from 'fs'
import { join } from 'path'
export async function initDatabase() {
const dbName = process.env.DB_NAME || 'xiaocai'
// 先用不指定数据库的连接,确保数据库存在
const bootstrap = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
multipleStatements: true,
})
await bootstrap.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`)
await bootstrap.end()
// 用独立连接执行建表和填充(需要 multipleStatements
const initConn = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'xiaocai',
password: process.env.DB_PASSWORD || 'xiaocai123',
database: dbName,
multipleStatements: true,
})
try {
const schemaPath = join(__dirname, 'schema.sql')
const seedPath = join(__dirname, 'seed.sql')
const schema = readFileSync(schemaPath, 'utf-8')
const cleanSchema = schema
.replace(/CREATE DATABASE.*?;/gi, '')
.replace(/USE\s+\w+;/gi, '')
await initConn.query(cleanSchema)
const seed = readFileSync(seedPath, 'utf-8')
await initConn.query(seed)
console.log('[DB] Database initialized')
} catch (err: any) {
if (err.code === 'ER_DUP_ENTRY' || err.errno === 1062) {
console.log('[DB] Already initialized, skipping seed')
} else {
console.error('[DB] Init failed:', err.message)
}
} finally {
await initConn.end()
}
}

48
server/src/db/schema.sql Normal file
View File

@@ -0,0 +1,48 @@
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(100) UNIQUE,
session_key VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS categories (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义',
name VARCHAR(50) NOT NULL,
icon VARCHAR(50) NOT NULL,
color VARCHAR(20) NOT NULL,
type ENUM('expense', 'income') NOT NULL,
sort_order INT DEFAULT 0,
is_custom TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_name_type (user_id, name, type),
INDEX idx_user_custom (user_id, is_custom)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS transactions (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount INT NOT NULL COMMENT '单位:分',
type ENUM('expense', 'income') NOT NULL,
category_id INT,
note VARCHAR(200),
date DATE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date),
INDEX idx_user_type_date (user_id, type, date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS budgets (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
amount INT NOT NULL COMMENT '单位:分',
month VARCHAR(7) NOT NULL COMMENT '格式2026-05',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_month (user_id, month),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

17
server/src/db/seed.sql Normal file
View File

@@ -0,0 +1,17 @@
INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VALUES
(0, '餐饮', 'Coffee', '#FF8C69', 'expense', 1),
(0, '饮品', 'Coffee', '#FF8C69', 'expense', 2),
(0, '购物', 'ShoppingBag', '#FFB347', 'expense', 3),
(0, '交通', 'Car', '#6C9BCF', 'expense', 4),
(0, '住房', 'Home', '#7BC67E', 'expense', 5),
(0, '医疗', 'Heart', '#FF6B6B', 'expense', 6),
(0, '教育', 'BookOpen', '#6C9BCF', 'expense', 7),
(0, '娱乐', 'Film', '#B39DDB', 'expense', 8),
(0, '服饰', 'Shirt', '#FFD166', 'expense', 9),
(0, '美容', 'Smile', '#FF8C69', 'expense', 10),
(0, '通讯', 'Smartphone', '#6C9BCF', 'expense', 11),
(0, '其他', 'MoreHorizontal', '#BFB3B3', 'expense', 12),
(0, '工资', 'DollarSign', '#7BC67E', 'income', 1),
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4);

74
server/src/index.ts Normal file
View File

@@ -0,0 +1,74 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import { authMiddleware } from './middleware/auth'
import { initDatabase } from './db/init'
import pool from './db/connection'
import authRoutes from './routes/auth'
import transactionRoutes from './routes/transaction'
import statsRoutes from './routes/stats'
import budgetRoutes from './routes/budget'
import categoryRoutes from './routes/category'
// Warn if token secret is using default fallback
if (!process.env.TOKEN_SECRET) {
console.warn('[Security] TOKEN_SECRET not set — using default fallback. Set TOKEN_SECRET in .env for production!')
}
const app = express()
const PORT = process.env.PORT || 3000
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS || '').split(',').filter(Boolean)
app.use(cors({
origin: (origin, callback) => {
// No origin = WeChat mini-program request (always allow)
if (!origin) return callback(null, true)
// If no CORS_ORIGINS configured, allow all (dev mode)
if (ALLOWED_ORIGINS.length === 0) return callback(null, true)
// Check whitelist
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true)
callback(null, false)
},
credentials: true,
}))
app.use(express.json({ limit: '10kb' }))
app.use(authMiddleware)
app.use('/api/auth', authRoutes)
app.use('/api/transactions', transactionRoutes)
app.use('/api/stats', statsRoutes)
app.use('/api/budget', budgetRoutes)
app.use('/api/categories', categoryRoutes)
app.get('/api/health', async (_req, res) => {
try {
await pool.query('SELECT 1')
res.json({ status: 'ok', db: 'connected', time: new Date().toISOString() })
} catch {
res.status(503).json({ status: 'error', db: 'disconnected', time: new Date().toISOString() })
}
})
let server: any
initDatabase().then(() => {
server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
})
// Graceful shutdown
function shutdown(signal: string) {
console.log(`[Server] ${signal} received, shutting down gracefully...`)
server?.close(() => {
pool.end().then(() => {
console.log('[Server] All connections closed')
process.exit(0)
})
})
// Force exit after 10s
setTimeout(() => process.exit(1), 10000)
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))

View File

@@ -0,0 +1,64 @@
import { Request, Response, NextFunction } from 'express'
import { createHmac } from 'crypto'
export interface AuthRequest extends Request {
userId?: number
}
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
const TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000 // 30 days
// 不需要认证的路径
const PUBLIC_PATHS = ['/api/auth/login', '/api/health']
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
// 公开路径跳过认证
if (PUBLIC_PATHS.some(p => req.path === p)) {
return next()
}
// 从 Authorization header 获取 token
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ code: 40100, message: '未登录' })
}
const token = authHeader.slice(7)
try {
const decoded = Buffer.from(token, 'base64').toString('utf-8')
const parts = decoded.split(':')
if (parts.length !== 3) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
const [userIdStr, timestamp, signature] = parts
const userId = parseInt(userIdStr)
const ts = parseInt(timestamp)
if (isNaN(userId) || isNaN(ts)) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
// Check expiry and reject future timestamps
const now = Date.now()
if (now - ts > TOKEN_EXPIRY) {
return res.status(401).json({ code: 40101, message: 'token已过期' })
}
if (ts > now + 60000) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
// Verify HMAC signature (full length)
const payload = `${userId}:${timestamp}`
const expectedSig = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
if (signature !== expectedSig) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
req.userId = userId
} catch {
return res.status(401).json({ code: 40100, message: 'token无效' })
}
next()
}

53
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import { Router, Request, Response } from 'express'
import { createHmac } from 'crypto'
import pool from '../db/connection'
const router = Router()
const APPID = process.env.WX_APPID || ''
const SECRET = process.env.WX_SECRET || ''
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
export function signToken(userId: number): string {
const payload = `${userId}:${Date.now()}`
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
return Buffer.from(`${payload}:${signature}`).toString('base64')
}
router.post('/login', async (req: Request, res: Response) => {
try {
const { code } = req.body
if (!code) {
return res.status(400).json({ code: 40001, message: '缺少code参数' })
}
// 调用微信 code2session 接口
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${APPID}&secret=${SECRET}&js_code=${code}&grant_type=authorization_code`
const wxRes = await fetch(url)
const wxData = await wxRes.json() as any
if (wxData.errcode) {
console.error('[Auth] WeChat API error:', wxData.errcode, wxData.errmsg)
return res.status(400).json({ code: 40002, message: '微信登录失败' })
}
const { openid, session_key } = wxData
// 查找或创建用户 (atomic: handles race condition)
const [result] = await pool.query(
'INSERT INTO users (openid, session_key) VALUES (?, ?) ON DUPLICATE KEY UPDATE session_key = ?, id = LAST_INSERT_ID(id)',
[openid, session_key, session_key]
)
const userId = (result as any).insertId
// HMAC signed token with expiry
const token = signToken(userId)
res.json({ code: 0, data: { token, userId } })
} catch (err: any) {
console.error('[Auth] Login failed:', err.message)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,49 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { getCurrentMonth, getMonthRange } from '../utils/date'
const router = Router()
function validateMonth(m: string): string {
return getMonthRange(m) ? m : getCurrentMonth()
}
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const month = validateMonth((req.query.month as string) || getCurrentMonth())
const [rows] = await pool.query(
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
[req.userId, month]
)
const budget = (rows as any[])[0] || null
res.json({ code: 0, data: budget })
} catch (err) {
console.error('[Budget] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { amount, month } = req.body
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' })
}
const m = validateMonth(month || getCurrentMonth())
await pool.query(
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
[req.userId, amount, m, amount]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Budget] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,57 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
router.get('/', async (req: AuthRequest, res: Response) => {
try {
// Returns: default categories (user_id=0) + user's own custom categories
const [rows] = await pool.query(
'SELECT * FROM categories WHERE user_id = 0 OR user_id = ? ORDER BY sort_order',
[req.userId]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Category] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { name, icon, color, type } = req.body
if (!name || !icon || !color || !type) {
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
}
if (!['expense', 'income'].includes(type)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
const [result] = await pool.query(
'INSERT INTO categories (user_id, name, icon, color, type, is_custom) VALUES (?, ?, ?, ?, ?, 1)',
[req.userId, name, icon, color, type]
)
res.json({ code: 0, data: { id: (result as any).insertId } })
} catch (err) {
console.error('[Category] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(
'DELETE FROM categories WHERE id = ? AND is_custom = 1 AND user_id = ?',
[req.params.id, req.userId]
)
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '分类不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Category] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,91 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { getCurrentMonth, getMonthRange } from '../utils/date'
const router = Router()
router.get('/overview', async (req: AuthRequest, res: Response) => {
try {
const { month } = req.query
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const [rows] = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as income,
COUNT(*) as count
FROM transactions WHERE user_id = ? AND date >= ? AND date <= ?`,
[req.userId, startDate, endDate]
)
const row = (rows as any[])[0]
const now = new Date()
const [year, mon] = m.split('-').map(Number)
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
const elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0
res.json({
code: 0,
data: { expense: row.expense, income: row.income, count: row.count, daily }
})
} catch (err) {
console.error('[Stats] overview error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.get('/category', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense' } = req.query
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const [rows] = await pool.query(
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ? AND t.date >= ? AND t.date <= ?
GROUP BY c.id
ORDER BY amount DESC`,
[req.userId, type, startDate, endDate]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Stats] category error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.get('/trend', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense' } = req.query
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const [rows] = await pool.query(
`SELECT date, SUM(amount) as amount
FROM transactions
WHERE user_id = ? AND type = ? AND date >= ? AND date <= ?
GROUP BY date
ORDER BY date`,
[req.userId, type, startDate, endDate]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Stats] trend error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -0,0 +1,165 @@
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
const VALID_TYPES = ['expense', 'income']
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
function safeInt(val: any, fallback: number, min: number, max: number): number {
const n = parseInt(val)
if (isNaN(n)) return fallback
return Math.min(Math.max(n, min), max)
}
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.id = ? AND t.user_id = ?`,
[req.params.id, req.userId]
)
const tx = (rows as any[])[0]
if (!tx) {
return res.status(404).json({ code: 40400, message: '记录不存在' })
}
res.json({ code: 0, data: tx })
} catch (err) {
console.error('[Transaction] GET by ID error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const { page, pageSize, type, startDate, endDate } = req.query
const pSize = safeInt(pageSize, 20, 1, 100)
const pNum = safeInt(page, 1, 1, 99999)
const offset = (pNum - 1) * pSize
let where = 'WHERE t.user_id = ?'
const params: any[] = [req.userId]
if (type && VALID_TYPES.includes(type as string)) {
where += ' AND t.type = ?'; params.push(type)
}
if (startDate && DATE_REGEX.test(startDate as string)) {
where += ' AND t.date >= ?'; params.push(startDate)
}
if (endDate && DATE_REGEX.test(endDate as string)) {
where += ' AND t.date <= ?'; params.push(endDate)
}
const [rows] = await pool.query(
`SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${where}
ORDER BY t.date DESC, t.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]
)
const [countResult] = await pool.query(
`SELECT COUNT(*) as total FROM transactions t ${where}`,
params
)
res.json({
code: 0,
data: {
list: rows,
total: (countResult as any)[0].total,
page: pNum,
pageSize: pSize
}
})
} catch (err) {
console.error('[Transaction] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { amount, type, category_id, note, date } = req.body
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' })
}
if (!type || !date) {
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
}
if (!VALID_TYPES.includes(type)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
if (!DATE_REGEX.test(date)) {
return res.status(400).json({ code: 40003, message: '日期格式无效' })
}
if (note && note.length > 200) {
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
}
const [result] = await pool.query(
'INSERT INTO transactions (user_id, amount, type, category_id, note, date) VALUES (?, ?, ?, ?, ?, ?)',
[req.userId, amount, type, category_id || null, note || null, date]
)
res.json({ code: 0, data: { id: (result as any).insertId } })
} catch (err) {
console.error('[Transaction] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.put('/:id', async (req: AuthRequest, res: Response) => {
try {
const { amount, type, category_id, note, date } = req.body
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' })
}
if (!type || !date) {
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
}
if (!VALID_TYPES.includes(type)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
if (!DATE_REGEX.test(date)) {
return res.status(400).json({ code: 40003, message: '日期格式无效' })
}
if (note && note.length > 200) {
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
}
const [result] = await pool.query(
'UPDATE transactions SET amount = ?, type = ?, category_id = ?, note = ?, date = ? WHERE id = ? AND user_id = ?',
[amount, type, category_id || null, note || null, date, req.params.id, req.userId]
)
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '记录不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Transaction] PUT error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(
'DELETE FROM transactions WHERE id = ? AND user_id = ?',
[req.params.id, req.userId]
)
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '记录不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Transaction] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

17
server/src/utils/date.ts Normal file
View File

@@ -0,0 +1,17 @@
const MONTH_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/
export function getCurrentMonth(): string {
const d = new Date()
const offset = 8 * 60 * 60 * 1000
const local = new Date(d.getTime() + offset)
return local.toISOString().slice(0, 7)
}
export function getMonthRange(m: string): { startDate: string; endDate: string } | null {
if (!MONTH_REGEX.test(m)) return null
const [year, mon] = m.split('-').map(Number)
const startDate = `${m}-01`
const lastDay = new Date(year, mon, 0).getDate()
const endDate = `${m}-${String(lastDay).padStart(2, '0')}`
return { startDate, endDate }
}

19
server/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

1032
server/yarn.lock Normal file

File diff suppressed because it is too large Load Diff